diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..95d31f5f Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36b7a8c7..832cadaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,42 +7,72 @@ on: schedule: - cron: "0 2 * * 0" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read security-events: write jobs: - gate: - name: Tests + Lint + Types + lint: + name: Lint + Format + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev + - run: uv run ruff check src tests + - run: uv run ruff format --check src tests + + typecheck: + name: Types (mypy strict) runs-on: ubuntu-latest + if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev + - run: uv run mypy + + test: + name: Tests + Coverage + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + strategy: + matrix: + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" - - name: Install - run: python -m pip install -e ".[dev]" - - name: Ruff - run: ruff check src tests - - name: Mypy - run: mypy src - - name: Pytest - run: pytest -q + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --all-extras --group dev + - run: uv run pytest --cov=wardline --cov-report=term-missing --cov-fail-under=90 self-hosting-scan: name: Self-Hosting Scan (dogfood) runs-on: ubuntu-latest - needs: gate + needs: test if: github.event_name != 'schedule' steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" - - name: Install - run: python -m pip install -e ".[dev]" + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev - name: Scan self -> SARIF - run: wardline scan src/wardline --format sarif --output results.sarif + run: uv run wardline scan src/ --format sarif --output results.sarif - name: Upload SARIF if: always() uses: github/codeql-action/upload-sarif@v3 @@ -56,32 +86,32 @@ jobs: if: github.event_name == 'schedule' steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" - - name: Install - run: python -m pip install -e ".[dev]" + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev - name: Network tests - run: pytest -m network -v + run: uv run pytest -m network -v env: WARDLINE_OPENROUTER_API_KEY: ${{ secrets.WARDLINE_OPENROUTER_API_KEY }} docs: name: Docs (build + deploy) runs-on: ubuntu-latest - needs: gate + needs: test if: github.event_name != 'schedule' permissions: contents: write steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" - - name: Install - run: python -m pip install -e ".[docs]" + enable-cache: true + python-version: "3.13" + - run: uv sync --extra docs - name: Build (strict) - run: mkdocs build --strict + run: uv run mkdocs build --strict - name: Deploy (main only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: mkdocs gh-deploy --force + run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3478e62..19faeb4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,13 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" + enable-cache: true + python-version: "3.13" - name: Build - run: | - python -m pip install build - python -m build + run: uv build - uses: actions/upload-artifact@v4 with: name: dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..7947e4be --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 007cd523..2053932a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,117 @@ # Contributing to Wardline Wardline is a lightweight semantic-tainting static analyzer for Python, built -for small teams who want capable tooling without enterprise weight. +for small teams who want capable tooling without enterprise weight. Bug reports, +feature ideas, docs fixes, and code changes are all welcome. + +## Reporting bugs + +Open a [bug report](https://github.com/foundryside-dev/wardline/issues/new?template=bug_report.yml). Include: + +- Wardline version (`wardline --version`) +- Whether you hit it via the CLI or the MCP server +- A minimal decorated snippet that reproduces the finding (or its absence) +- Expected vs actual behavior +- Python version and OS + +## Suggesting features + +Open a [feature request](https://github.com/foundryside-dev/wardline/issues/new?template=feature_request.yml). Describe the problem you are solving and your proposed approach. ## Development setup +Wardline uses [uv](https://docs.astral.sh/uv/). + ```bash git clone https://github.com/foundryside-dev/wardline cd wardline -python -m venv .venv && . .venv/bin/activate -pip install -e ".[dev]" +uv sync --all-extras --group dev +``` + +This installs the base package, every runtime extra (`scanner`, `clarion`, +`docs`), and the dev tooling (ruff, mypy, pytest) into `.venv`. + +## Code style + +- **Linter / formatter:** [ruff](https://docs.astral.sh/ruff/) (config in `pyproject.toml`, line-length 120) +- **Type checker:** mypy in strict mode (`src/wardline` only) +- **Tests:** pytest, run under `pytest-randomly` (order-dependence is a real bug) + +Before committing: + +```bash +make format # auto-fix formatting and lint +make lint # check without modifying (same as CI) +make typecheck # mypy strict ``` -## Before opening a PR +A ruff pre-commit hook is available — `uv run --with pre-commit pre-commit install`. -Run the full gate and make sure it is green: +## Running tests ```bash -pytest -q -ruff check src tests -mypy src +make test # quick run +make test-cov # with coverage; CI enforces a 90% floor +``` + +The `network` (live OpenRouter judge) and `clarion_e2e` (real `clarion serve`) +suites are deselected by default. Opt in with `uv run pytest -m network` / +`uv run pytest -m clarion_e2e` (the latter needs a route-capable Clarion binary — +see `CLAUDE.md`). + +## Conventions + +- **TDD.** Write the failing test first. +- Keep PRs focused — one logical change per PR. +- New behavior needs tests. New `wardline.yaml` keys need a `config_schema.py` update. +- No back-compat shims for unreleased specs — make clean changes. +- Wardline scans its own source as a CI gate; keep the tree finding-clean (or baselined). + +## Commit messages + +This project uses [Conventional Commits](https://www.conventionalcommits.org/): + ``` +: +``` + +| Type | When to use | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `test` | Adding or updating tests | +| `ci` | CI/CD pipeline changes | +| `build` | Build system or packaging changes | +| `refactor` | Neither fixes a bug nor adds a feature | +| `style` | Formatting only | +| `chore` | Maintenance (deps, config) | + +Use `!` after the type for breaking changes: `refactor!: rename public API`. + +## Pull request process + +1. Branch from `main`. +2. Make your change (test-first). +3. Run `make ci` until green (ruff check + format check + mypy strict + pytest with the 90% coverage floor). +4. Open a PR against `main`, describing what and why; link related issues. +5. Ensure the CI checks pass. + +## First-time contributors + +Good starting points: documentation improvements, tests for uncovered paths, and +CLI help-text polish. + +## Architecture + +The big-picture developer guide — the L1/L2/L3 taint pipeline, the package map, +and the conventions — lives in [CLAUDE.md](CLAUDE.md). Read it before a +non-trivial change. + +## Code of Conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). + +## License -- Follow TDD: write the failing test first. -- Keep changes focused; one concern per PR. -- New behaviour needs tests. New `wardline.yaml` keys need a `config_schema.py` update. +By contributing, you agree your contributions are licensed under the [MIT License](LICENSE). diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..909e9798 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.DEFAULT_GOAL := help +.PHONY: help install lint format typecheck test test-cov scan-self docs build clean ci + +help: ## Show this help message + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +install: ## Install all extras + dev tooling + uv sync --all-extras --group dev + +lint: ## Run linter + format check + uv run ruff check src tests + uv run ruff format --check src tests + +format: ## Auto-format and fix lint + uv run ruff format src tests + uv run ruff check --fix src tests + +typecheck: ## Run mypy strict + uv run mypy + +test: ## Run tests (no coverage) + uv run pytest -q + +test-cov: ## Run tests with coverage gate (90%) + uv run pytest --cov=wardline --cov-report=term-missing --cov-fail-under=90 + +scan-self: ## Dogfood: scan wardline's own source + uv run wardline scan src/wardline --fail-on ERROR + +docs: ## Serve the docs site locally + uv run mkdocs serve + +build: ## Build sdist + wheel + uv build + +clean: ## Remove build + cache artifacts + rm -rf dist/ build/ *.egg-info .mypy_cache .ruff_cache .pytest_cache .coverage coverage.json + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +ci: lint typecheck test-cov ## Run the full local CI gate diff --git a/README.md b/README.md index 97c4c603..74b8c309 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,183 @@ # Wardline -Wardline is a generic, lightweight semantic-tainting static analyzer for Python. It tracks the flow of untrusted data through a codebase, identifies trust-boundary violations, and emits structured findings — without requiring runtime instrumentation. Wardline is part of the Loom suite alongside Clarion (code intelligence) and Filigree (issue tracking). To use the scanner CLI, install the extra dependencies with `pip install wardline[scanner]`. +Generic, lightweight semantic-tainting static analyzer for Python — track untrusted data across your codebase and gate trust-boundary violations, with zero runtime dependencies. + +[![CI](https://github.com/foundryside-dev/wardline/actions/workflows/ci.yml/badge.svg)](https://github.com/foundryside-dev/wardline/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/wardline)](https://pypi.org/project/wardline/) +[![Python 3.12+](https://img.shields.io/pypi/pyversions/wardline)](https://pypi.org/project/wardline/) +[![License: MIT](https://img.shields.io/pypi/l/wardline)](https://github.com/foundryside-dev/wardline/blob/main/LICENSE) + +```python +from wardline.decorators import trusted, external_boundary + +@external_boundary +def read_request(req): + return req.body # raw, untrusted (EXTERNAL_RAW) + +@trusted(level="ASSURED") +def build_record(req): + return read_request(req) # claims ASSURED, returns raw — no validation +``` + +```console +$ wardline scan . --fail-on ERROR +demo.build_record declares return trust ASSURED but actually returns +EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer [PY-WL-101] + demo.py:7 +gate: tripped (1 active defect >= ERROR) +$ echo $? +1 +``` + +## What is Wardline? + +Wardline reads your Python statically — it never runs your code — and asks one +question of every trust-annotated function: **is the data this function works +with as trusted as it claims?** It tracks a *taint* (a trust level) for every +value and propagates it across the whole project, flagging the places where +untrusted data reaches a trusted producer with no validation in between. + +Wardline is part of the **Loom** suite alongside **Clarion** (code intelligence) +and **Filigree** (issue tracking). It is built for small teams who want capable +analysis tooling without enterprise weight. + +**Opt-in by design.** Wardline is silent until you opt in. Undecorated code sits +in the developer-freedom zone — unknown-trust, no findings. You declare trust on +the functions that matter, and only then does Wardline enforce it. That is what +lets it scan a large untouched codebase (including its own) with zero noise. + +## Key Features + +- **Deterministic whole-program taint** — function-, variable-, and project-level + analysis over an inter-module call graph; no runtime instrumentation. +- **Opt-in trust model** — three decorators (`@external_boundary`, + `@trust_boundary`, `@trusted`) mark your boundaries; the engine infers the rest. +- **Four policy rules** — untrusted-reaches-trusted, non-rejecting boundary, + broad exception handler, and silently-swallowed exception. +- **Zero-dependency base** — `pip install wardline` pulls nothing; functionality + lives behind small extras. +- **Structured output** — JSONL, SARIF (GitHub code-scanning), and native + Filigree emit. +- **Agent-native** — `wardline mcp` is a dependency-free MCP-over-stdio server; + `wardline install` wires Wardline into your coding agent in one command. +- **Opt-in LLM triage** — `wardline judge` labels findings TRUE/FALSE positive + (dependency-free; never runs automatically). +- **Light-touch suppression** — baselines and time-boxed, reasoned waivers. +- **Clarion integration** — persist per-entity taint facts to a Clarion store. + +## Quick Start + +```bash +pip install wardline[scanner] +``` + +```python +# app.py +from wardline.decorators import trusted, external_boundary + +@external_boundary +def read_request(req): + return req.body + +@trusted(level="ASSURED") +def build_record(req): + return read_request(req) +``` + +```bash +wardline scan . --fail-on ERROR # exit 0 = clean, 1 = gate tripped, 2 = wardline error +``` + +Fix findings at the **boundary** (validate before returning), not at the sink. + +## Installation + +```bash +pip install wardline # zero-dependency base (library + decorators) +pip install wardline[scanner] # the scan/judge/baseline CLI + MCP server +``` + +| Extra | Pulls | Enables | +|-------|-------|---------| +| `scanner` | pyyaml, jsonschema, click | the `wardline` CLI and `wardline mcp` server | +| `clarion` | blake3 | persisting taint facts to a Clarion store | +| `docs` | mkdocs, mkdocs-material | building the documentation site | + +The LLM triage judge (`wardline judge`) is dependency-free (stdlib `urllib` → +OpenRouter) and needs no extra. + +## Use Wardline with your coding agent + +```bash +wardline install +``` + +This injects a hash-fenced instruction block into `CLAUDE.md`/`AGENTS.md`, +installs the `wardline-gate` skill, merges a `wardline` entry into `.mcp.json`, +and records Clarion/Filigree bindings if present. Agents then run the +scan → explain → fix-at-boundary → rescan loop natively. The `wardline mcp` +server exposes `scan`, `explain_taint`, `judge`, baseline, and waiver tools over +JSON-RPC with no SDK. + +## Where Wardline fits + +Use Wardline when you want a deterministic, opt-in trust-boundary gate you can +run in CI and hand to an agent — lightweight, Python-native, no external service. + +It is **not** the right tool when you need: + +- **Full interprocedural everything.** Wardline is precise at the function and + project-call-graph level (L1–L2 with an L3 fixed point), not an exhaustive, + path-sensitive whole-program prover. +- **A broad SAST suite.** Wardline checks trust boundaries and a small set of + exception-handling rules; it is not a replacement for a general-purpose + scanner that covers dozens of vulnerability classes. +- **Non-Python code.** Wardline analyzes Python ≥3.12 only. +- **Zero-config coverage.** Wardline is silent until you declare trust — that is + the point, but it means it finds nothing on an un-annotated codebase. ## Documentation -Full documentation — getting started, concepts, configuration, the LLM triage -judge, Loom integration, and using Wardline with your coding agent — lives at -****. +Full documentation lives at ****. + +| Document | Description | +|----------|-------------| +| [Getting Started](https://foundryside-dev.github.io/wardline/getting-started/) | Install, decorate, first scan | +| [Taint & Trust Model](https://foundryside-dev.github.io/wardline/concepts/model/) | The lattice, decorators, and propagation | +| [Rules](https://foundryside-dev.github.io/wardline/concepts/rules/) | The four policy rules | +| [Configuration](https://foundryside-dev.github.io/wardline/guides/configuration/) | `wardline.yaml`: rules, severity, excludes | +| [Suppression](https://foundryside-dev.github.io/wardline/guides/suppression/) | Baselines and waivers | +| [LLM Triage Judge](https://foundryside-dev.github.io/wardline/guides/judge/) | Opt-in TRUE/FALSE-positive labelling | +| [Clarion Taint Store](https://foundryside-dev.github.io/wardline/guides/clarion-taint-store/) | Persisting taint facts | +| [CLI Reference](https://foundryside-dev.github.io/wardline/reference/cli/) | Every command and flag | +| [Trust Vocabulary](https://foundryside-dev.github.io/wardline/reference/vocabulary/) | The decorators and their arguments | +| [Agent Integration](https://foundryside-dev.github.io/wardline/agents/) | Using Wardline from a coding agent | + +## Development + +Requires Python ≥3.12. Developed on 3.13 with [uv](https://docs.astral.sh/uv/). + +```bash +git clone https://github.com/foundryside-dev/wardline +cd wardline +uv sync --all-extras --group dev + +make ci # ruff check + format check + mypy strict + pytest (90% coverage floor) +make lint # ruff check + format --check +make format # auto-format and fix +make typecheck # mypy strict +make test # pytest +make scan-self # dogfood: scan wardline's own source +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow and +[CLAUDE.md](CLAUDE.md) for the developer architecture guide. + +## Acknowledgements + +Wardline is one of the **Loom** tools (with Clarion and Filigree) — small, +local-first, agent-native developer tooling. + +## License + +[MIT](LICENSE) — Copyright (c) 2026 John Morrissey diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..5f5814fc --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,44 @@ +# Wardline Roadmap + +Wardline is a lightweight, opt-in semantic-tainting analyzer for Python. This is +a direction sketch, not a commitment — dates are deliberately omitted. + +## Where we are + +**0.3.0 — shipped.** The staged build (SP0–SP9) is complete: + +- Function-, variable-, and project-level taint over an inter-module call graph + (L1–L2 with an L3 fixed point). +- The NG-25 trust vocabulary and three opt-in decorators. +- Four policy rules (PY-WL-101..104), severity/enable config, baselines + waivers. +- JSONL + SARIF + native Filigree emit. +- Dependency-free MCP-over-stdio server (`wardline mcp`). +- Opt-in LLM triage judge (`wardline judge`). +- `wardline install` agent enablement. +- Opt-in Clarion taint-store integration. +- Published to PyPI; docs site live; CI dogfoods Wardline on its own source. + +## Scope + +Wardline is deliberately **L1–L2 with an L3 project fixed point**, not an +exhaustive path-sensitive whole-program prover, and Python-only. We favor a +small, precise, opt-in rule set over broad SAST coverage. + +## Near-term threads + +Tracked in the project's Filigree issues: + +- **N-hop `explain_taint` chain completeness** — full boundary-chain reconstruction + on the explain surface (`wardline-82f49ec3c3`). +- **Return-indirection in `compute_return_callee`** — explain-surface completeness + for returns routed through intermediates (`wardline-82f49ec3c3`). +- **Taint-combination hardening** — first-class hardening from the 2026-05-31 + audit (`wardline-2b138b3662`). +- **Star-import decorator markers** — resolve `from x import *` so trust markers + are not missed (`wardline-2b427a9579`). + +## Out of scope (for now) + +- Languages other than Python. +- A general-purpose, dozens-of-rules SAST suite. +- A hosted/cloud service — Wardline stays local-first. diff --git a/docs/superpowers/plans/2026-06-01-wardline-repo-standardization.md b/docs/superpowers/plans/2026-06-01-wardline-repo-standardization.md new file mode 100644 index 00000000..cc3c0883 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-wardline-repo-standardization.md @@ -0,0 +1,1096 @@ +# Wardline Repo Standardization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the Wardline repo up to the filigree presentation standard (README, CONTRIBUTING, ROADMAP, Makefile, pre-commit, hardened CI, full uv parity) and document the use ⇄ develop split — with zero engine/source behavior changes. + +**Architecture:** Pure tooling + docs pass. Migrate the dev workflow to `uv` (PEP 735 dependency-groups, `uv.lock`, `.python-version`), keep hatchling as the build backend and `scanner`/`clarion`/`docs` as runtime extras. Split CI into lint/typecheck/test(matrix)+coverage and keep the dogfood/network/docs jobs. Rewrite the README to filigree class, expand the project docs, and mirror developer guidance into `CLAUDE.md` + `AGENTS.md` while leaving the user-facing surfaces (install block, `wardline-gate` skill, `docs/agents.md`) untouched. + +**Tech Stack:** uv 0.10.2, hatchling, ruff, mypy (strict), pytest + pytest-cov, GitHub Actions (`astral-sh/setup-uv@v7`), mkdocs-material, PyPI Trusted Publishing. + +**Spec:** `docs/superpowers/specs/2026-06-01-wardline-repo-standardization-design.md` +**Branch:** `docs/repo-standardization` (already created off `main`) + +--- + +## File Structure + +**Create:** +- `.python-version` — pins the dev interpreter (3.13) +- `uv.lock` — generated lockfile +- `Makefile` — dev task entry points +- `.pre-commit-config.yaml` — ruff hooks +- `ROADMAP.md` — current state + near-term direction + +**Modify:** +- `pyproject.toml` — `dev` extra → `[dependency-groups]`; add `[tool.coverage.*]` +- `.github/workflows/ci.yml` — uv + split jobs + matrix + coverage floor +- `.github/workflows/release.yml` — build step → `uv build` (publish job unchanged) +- `README.md` — 9-line stub → filigree-class +- `CONTRIBUTING.md` — expand to filigree depth (uv, conventional commits, PR gate) +- `CODE_OF_CONDUCT.md` — stub → canonical Contributor Covenant (fetched, not inlined here) +- `CLAUDE.md` — add Audience banner +- `AGENTS.md` — mirror developer guidance above the Filigree block + Audience banner +- Possibly `src/wardline/**`, `tests/**` — formatting-only churn from `ruff format` (Task 2) + +**Leave untouched (use-surface, by design):** `src/wardline/install/block.py`, `src/wardline/skills/wardline-gate/SKILL.md`, `docs/agents.md`, `SECURITY.md`, the mkdocs doc-site pages. + +--- + +## Task 1: Migrate the dev toolchain to uv + +**Files:** +- Modify: `pyproject.toml` (the `[project.optional-dependencies].dev` block at lines ~28-38; add coverage config) +- Create: `.python-version` +- Create: `uv.lock` (generated) + +- [ ] **Step 1: Replace the `dev` optional-dependency with a PEP 735 dependency-group** + +In `pyproject.toml`, delete the `dev = [...]` entry from `[project.optional-dependencies]` (keep `scanner`, `docs`, `clarion`). The block becomes: + +```toml +[project.optional-dependencies] +scanner = ["pyyaml>=6.0", "jsonschema>=4.0", "click>=8.0"] +docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"] +clarion = ["blake3>=1.0"] +# The SP5 LLM triage judge is dependency-free (stdlib urllib -> OpenRouter); no extra needed. +``` + +Then add a new top-level table (after `[project.optional-dependencies]`): + +```toml +[dependency-groups] +# Tooling only. Runtime deps live in the extras above; `uv sync --all-extras +# --group dev` installs base + scanner + clarion + docs + this group, which is +# the canonical dev sync used by the Makefile, CI, and CONTRIBUTING. +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-randomly", + "ruff>=0.8.0", + "mypy>=1.13.0", + "types-PyYAML", + "types-jsonschema", +] +``` + +- [ ] **Step 2: Add coverage configuration** + +Append to `pyproject.toml`: + +```toml +[tool.coverage.run] +source = ["wardline"] +branch = true + +[tool.coverage.report] +show_missing = true +``` + +Do NOT add `--cov-fail-under` to `[tool.pytest.ini_options].addopts` — the floor lives in CI and `make test-cov` so bare `pytest` stays fast. Leave `addopts = "-m 'not network and not clarion_e2e'"` as-is. + +- [ ] **Step 3: Create `.python-version`** + +``` +3.13 +``` + +- [ ] **Step 4: Generate the lockfile and sync** + +Run: +```bash +uv lock +uv sync --all-extras --group dev +``` +Expected: `uv.lock` is created; sync creates/updates `.venv` and reports the resolved packages with no error. + +- [ ] **Step 5: Verify the suite still passes under uv** + +Run: `uv run pytest -q` +Expected: `1001 passed, 2 deselected` (same as the pip venv). + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml .python-version uv.lock +git commit -m "build: adopt uv (dependency-groups, lockfile, .python-version) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Format the tree once so `ruff format --check` will pass + +**Files:** +- Modify: `src/wardline/**`, `tests/**` (formatting-only, if any) + +CI (Task 5) introduces `ruff format --check`. The repo was previously linted but never `ruff format`-ed, so format once now and confirm the diff is formatting-only. + +- [ ] **Step 1: Run the formatter** + +Run: `uv run ruff format src tests` +Expected: prints `N files reformatted, M files left unchanged` (N may be 0). + +- [ ] **Step 2: Confirm the diff is formatting-only** + +Run: `git diff --stat` then spot-check `git diff`. +Expected: only whitespace/quote/line-wrap changes — NO logic changes. If any change looks semantic, stop and investigate (ruff format should never alter behavior). + +- [ ] **Step 3: Verify lint + types + tests still green** + +Run: +```bash +uv run ruff check src tests +uv run ruff format --check src tests +uv run mypy +uv run pytest -q +``` +Expected: ruff check clean; `format --check` reports all files already formatted; mypy clean; `1001 passed`. + +- [ ] **Step 4: Commit (skip if Step 1 reformatted 0 files)** + +```bash +git add -A +git commit -m "style: apply ruff format across src and tests + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Add the Makefile + +**Files:** +- Create: `Makefile` + +- [ ] **Step 1: Write the Makefile** + +```makefile +.DEFAULT_GOAL := help +.PHONY: help install lint format typecheck test test-cov scan-self docs build clean ci + +help: ## Show this help message + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +install: ## Install all extras + dev tooling + uv sync --all-extras --group dev + +lint: ## Run linter + format check + uv run ruff check src tests + uv run ruff format --check src tests + +format: ## Auto-format and fix lint + uv run ruff format src tests + uv run ruff check --fix src tests + +typecheck: ## Run mypy strict + uv run mypy + +test: ## Run tests (no coverage) + uv run pytest -q + +test-cov: ## Run tests with coverage gate (90%) + uv run pytest --cov=wardline --cov-report=term-missing --cov-fail-under=90 + +scan-self: ## Dogfood: scan wardline's own source + uv run wardline scan src/wardline --fail-on ERROR + +docs: ## Serve the docs site locally + uv run mkdocs serve + +build: ## Build sdist + wheel + uv build + +clean: ## Remove build + cache artifacts + rm -rf dist/ build/ *.egg-info .mypy_cache .ruff_cache .pytest_cache .coverage coverage.json + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +ci: lint typecheck test-cov ## Run the full local CI gate +``` + +- [ ] **Step 2: Verify each target resolves** + +Run: `make help` +Expected: prints the target list with descriptions. + +Run: `make ci` +Expected: ruff check + format-check + mypy clean, then pytest reports `1001 passed` and coverage `TOTAL ... 94%` (≥90%, gate passes). + +- [ ] **Step 3: Commit** + +```bash +git add Makefile +git commit -m "build: add Makefile with ci/lint/format/test targets + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Add the pre-commit config + +**Files:** +- Create: `.pre-commit-config.yaml` + +- [ ] **Step 1: Write the config** + +```yaml +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format +``` + +(mypy is deliberately a CI/`make` gate, not a pre-commit hook, to keep commits fast.) + +- [ ] **Step 2: Verify it parses** + +Run: `python -c "import yaml,sys; yaml.safe_load(open('.pre-commit-config.yaml')); print('ok')"` +Expected: `ok`. + +(If `pre-commit` is installed: `uv run --with pre-commit pre-commit run --all-files` — optional; not required to pass since the tree was just formatted in Task 2.) + +- [ ] **Step 3: Commit** + +```bash +git add .pre-commit-config.yaml +git commit -m "build: add ruff pre-commit hooks + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Harden CI + +**Files:** +- Modify: `.github/workflows/ci.yml` (full rewrite) + +- [ ] **Step 1: Rewrite `ci.yml`** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 2 * * 0" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + lint: + name: Lint + Format + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev + - run: uv run ruff check src tests + - run: uv run ruff format --check src tests + + typecheck: + name: Types (mypy strict) + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev + - run: uv run mypy + + test: + name: Tests + Coverage + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --all-extras --group dev + - run: uv run pytest --cov=wardline --cov-report=term-missing --cov-fail-under=90 + + self-hosting-scan: + name: Self-Hosting Scan (dogfood) + runs-on: ubuntu-latest + needs: test + if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev + - name: Scan self -> SARIF + run: uv run wardline scan src/wardline --format sarif --output results.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + category: wardline-self-hosting + + network: + name: Live judge e2e (weekly) + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --all-extras --group dev + - name: Network tests + run: uv run pytest -m network -v + env: + WARDLINE_OPENROUTER_API_KEY: ${{ secrets.WARDLINE_OPENROUTER_API_KEY }} + + docs: + name: Docs (build + deploy) + runs-on: ubuntu-latest + needs: test + if: github.event_name != 'schedule' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - run: uv sync --extra docs + - name: Build (strict) + run: uv run mkdocs build --strict + - name: Deploy (main only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: uv run mkdocs gh-deploy --force +``` + +- [ ] **Step 2: Validate the workflow YAML** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Locally reproduce each gate the CI runs** + +Run: +```bash +uv run ruff check src tests +uv run ruff format --check src tests +uv run mypy +uv run pytest --cov=wardline --cov-report=term-missing --cov-fail-under=90 +uv run wardline scan src/wardline --format sarif --output /tmp/results.sarif && echo "scan ok" +uv run mkdocs build --strict +``` +Expected: every command exits 0; coverage ≥90%; mkdocs builds with no warnings; `scan ok` prints. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: uv + 3.12/3.13 matrix + 90% coverage floor + format check + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: Switch the release build to `uv build` (keep PyPI publish) + +**Files:** +- Modify: `.github/workflows/release.yml` (the `build` job only) + +- [ ] **Step 1: Replace the build step** + +In `release.yml`, the `build` job's steps become: + +```yaml + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.13" + - name: Build + run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ +``` + +Leave the `publish` job EXACTLY as it is (download-artifact → `pypa/gh-action-pypi-publish@release/v1`, `environment: pypi`, `permissions: id-token: write`). This is the hard constraint: packages must still publish to PyPI via Trusted Publishing. + +- [ ] **Step 2: Validate YAML + local build** + +Run: +```bash +python -c "import yaml; d=yaml.safe_load(open('.github/workflows/release.yml')); assert 'publish' in d['jobs'] and d['jobs']['publish']['environment']=='pypi'; print('publish job intact')" +uv build +ls dist/ +uv run --with twine twine check dist/* +``` +Expected: `publish job intact`; `dist/` contains a `.tar.gz` sdist and a `.whl`; `twine check` reports `PASSED` for both. Confirm the wheel includes the force-included data files: +```bash +python -c "import zipfile,glob; w=glob.glob('dist/*.whl')[0]; names=zipfile.ZipFile(w).namelist(); assert any('stdlib_taint.yaml' in n for n in names) and any('vocabulary.yaml' in n for n in names) and any('SKILL.md' in n for n in names); print('data files packaged')" +``` +Expected: `data files packaged`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: build release artifacts with uv build (publish job unchanged) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Rewrite the README + +**Files:** +- Modify: `README.md` (full replace) + +- [ ] **Step 1: Write the new README** + +````markdown +# Wardline + +Generic, lightweight semantic-tainting static analyzer for Python — track untrusted data across your codebase and gate trust-boundary violations, with zero runtime dependencies. + +[![CI](https://github.com/foundryside-dev/wardline/actions/workflows/ci.yml/badge.svg)](https://github.com/foundryside-dev/wardline/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/wardline)](https://pypi.org/project/wardline/) +[![Python 3.12+](https://img.shields.io/pypi/pyversions/wardline)](https://pypi.org/project/wardline/) +[![License: MIT](https://img.shields.io/pypi/l/wardline)](https://github.com/foundryside-dev/wardline/blob/main/LICENSE) + +```python +from wardline.decorators import trusted, external_boundary + +@external_boundary +def read_request(req): + return req.body # raw, untrusted (EXTERNAL_RAW) + +@trusted(level="ASSURED") +def build_record(req): + return read_request(req) # claims ASSURED, returns raw — no validation +``` + +```console +$ wardline scan . --fail-on ERROR +demo.build_record declares return trust ASSURED but actually returns +EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer [PY-WL-101] + demo.py:7 +gate: tripped (1 active defect >= ERROR) +$ echo $? +1 +``` + +## What is Wardline? + +Wardline reads your Python statically — it never runs your code — and asks one +question of every trust-annotated function: **is the data this function works +with as trusted as it claims?** It tracks a *taint* (a trust level) for every +value and propagates it across the whole project, flagging the places where +untrusted data reaches a trusted producer with no validation in between. + +Wardline is part of the **Loom** suite alongside **Clarion** (code intelligence) +and **Filigree** (issue tracking). It is built for small teams who want capable +analysis tooling without enterprise weight. + +**Opt-in by design.** Wardline is silent until you opt in. Undecorated code sits +in the developer-freedom zone — unknown-trust, no findings. You declare trust on +the functions that matter, and only then does Wardline enforce it. That is what +lets it scan a large untouched codebase (including its own) with zero noise. + +## Key Features + +- **Deterministic whole-program taint** — function-, variable-, and project-level + analysis over an inter-module call graph; no runtime instrumentation. +- **Opt-in trust model** — three decorators (`@external_boundary`, + `@trust_boundary`, `@trusted`) mark your boundaries; the engine infers the rest. +- **Four policy rules** — untrusted-reaches-trusted, non-rejecting boundary, + broad exception handler, and silently-swallowed exception. +- **Zero-dependency base** — `pip install wardline` pulls nothing; functionality + lives behind small extras. +- **Structured output** — JSONL, SARIF (GitHub code-scanning), and native + Filigree emit. +- **Agent-native** — `wardline mcp` is a dependency-free MCP-over-stdio server; + `wardline install` wires Wardline into your coding agent in one command. +- **Opt-in LLM triage** — `wardline judge` labels findings TRUE/FALSE positive + (dependency-free; never runs automatically). +- **Light-touch suppression** — baselines and time-boxed, reasoned waivers. +- **Clarion integration** — persist per-entity taint facts to a Clarion store. + +## Quick Start + +```bash +pip install wardline[scanner] +``` + +```python +# app.py +from wardline.decorators import trusted, external_boundary + +@external_boundary +def read_request(req): + return req.body + +@trusted(level="ASSURED") +def build_record(req): + return read_request(req) +``` + +```bash +wardline scan . --fail-on ERROR # exit 0 = clean, 1 = gate tripped, 2 = wardline error +``` + +Fix findings at the **boundary** (validate before returning), not at the sink. + +## Installation + +```bash +pip install wardline # zero-dependency base (library + decorators) +pip install wardline[scanner] # the scan/judge/baseline CLI + MCP server +``` + +| Extra | Pulls | Enables | +|-------|-------|---------| +| `scanner` | pyyaml, jsonschema, click | the `wardline` CLI and `wardline mcp` server | +| `clarion` | blake3 | persisting taint facts to a Clarion store | +| `docs` | mkdocs, mkdocs-material | building the documentation site | + +The LLM triage judge (`wardline judge`) is dependency-free (stdlib `urllib` → +OpenRouter) and needs no extra. + +## Use Wardline with your coding agent + +```bash +wardline install +``` + +This injects a hash-fenced instruction block into `CLAUDE.md`/`AGENTS.md`, +installs the `wardline-gate` skill, merges a `wardline` entry into `.mcp.json`, +and records Clarion/Filigree bindings if present. Agents then run the +scan → explain → fix-at-boundary → rescan loop natively. The `wardline mcp` +server exposes `scan`, `explain_taint`, `judge`, baseline, and waiver tools over +JSON-RPC with no SDK. + +## Where Wardline fits + +Use Wardline when you want a deterministic, opt-in trust-boundary gate you can +run in CI and hand to an agent — lightweight, Python-native, no external service. + +It is **not** the right tool when you need: + +- **Full interprocedural everything.** Wardline is precise at the function and + project-call-graph level (L1–L2 with an L3 fixed point), not an exhaustive, + path-sensitive whole-program prover. +- **A broad SAST suite.** Wardline checks trust boundaries and a small set of + exception-handling rules; it is not a replacement for a general-purpose + scanner that covers dozens of vulnerability classes. +- **Non-Python code.** Wardline analyzes Python ≥3.12 only. +- **Zero-config coverage.** Wardline is silent until you declare trust — that is + the point, but it means it finds nothing on an un-annotated codebase. + +## Documentation + +Full documentation lives at ****. + +| Document | Description | +|----------|-------------| +| [Getting Started](https://foundryside-dev.github.io/wardline/getting-started/) | Install, decorate, first scan | +| [Taint & Trust Model](https://foundryside-dev.github.io/wardline/concepts/model/) | The lattice, decorators, and propagation | +| [Rules](https://foundryside-dev.github.io/wardline/concepts/rules/) | The four policy rules | +| [Configuration](https://foundryside-dev.github.io/wardline/guides/configuration/) | `wardline.yaml`: rules, severity, excludes | +| [Suppression](https://foundryside-dev.github.io/wardline/guides/suppression/) | Baselines and waivers | +| [LLM Triage Judge](https://foundryside-dev.github.io/wardline/guides/judge/) | Opt-in TRUE/FALSE-positive labelling | +| [Clarion Taint Store](https://foundryside-dev.github.io/wardline/guides/clarion-taint-store/) | Persisting taint facts | +| [CLI Reference](https://foundryside-dev.github.io/wardline/reference/cli/) | Every command and flag | +| [Trust Vocabulary](https://foundryside-dev.github.io/wardline/reference/vocabulary/) | The decorators and their arguments | +| [Agent Integration](https://foundryside-dev.github.io/wardline/agents/) | Using Wardline from a coding agent | + +## Development + +Requires Python ≥3.12. Developed on 3.13 with [uv](https://docs.astral.sh/uv/). + +```bash +git clone https://github.com/foundryside-dev/wardline +cd wardline +uv sync --all-extras --group dev + +make ci # ruff check + format check + mypy strict + pytest (90% coverage floor) +make lint # ruff check + format --check +make format # auto-format and fix +make typecheck # mypy strict +make test # pytest +make scan-self # dogfood: scan wardline's own source +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow and +[CLAUDE.md](CLAUDE.md) for the developer architecture guide. + +## Acknowledgements + +Wardline is one of the **Loom** tools (with Clarion and Filigree) — small, +local-first, agent-native developer tooling. + +## License + +[MIT](LICENSE) — Copyright (c) 2026 John Morrissey +```` + +- [ ] **Step 2: Verify links and that the hero example matches the docs** + +Run: `grep -c "foundryside-dev.github.io/wardline" README.md` +Expected: ≥10 (the doc table + intro). + +Confirm the hero `PY-WL-101` example matches `docs/concepts/model.md` (the `read_request`/`build_record` pair). Read both and check they agree. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: filigree-class README (features, quick start, agent setup, fit) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Expand CONTRIBUTING + +**Files:** +- Modify: `CONTRIBUTING.md` (full replace) + +- [ ] **Step 1: Write the new CONTRIBUTING** + +````markdown +# Contributing to Wardline + +Wardline is a lightweight semantic-tainting static analyzer for Python, built +for small teams who want capable tooling without enterprise weight. Bug reports, +feature ideas, docs fixes, and code changes are all welcome. + +## Reporting bugs + +Open a [bug report](https://github.com/foundryside-dev/wardline/issues/new?template=bug_report.yml). Include: + +- Wardline version (`wardline --version`) +- Whether you hit it via the CLI or the MCP server +- A minimal decorated snippet that reproduces the finding (or its absence) +- Expected vs actual behavior +- Python version and OS + +## Suggesting features + +Open a [feature request](https://github.com/foundryside-dev/wardline/issues/new?template=feature_request.yml). Describe the problem you are solving and your proposed approach. + +## Development setup + +Wardline uses [uv](https://docs.astral.sh/uv/). + +```bash +git clone https://github.com/foundryside-dev/wardline +cd wardline +uv sync --all-extras --group dev +``` + +This installs the base package, every runtime extra (`scanner`, `clarion`, +`docs`), and the dev tooling (ruff, mypy, pytest) into `.venv`. + +## Code style + +- **Linter / formatter:** [ruff](https://docs.astral.sh/ruff/) (config in `pyproject.toml`, line-length 120) +- **Type checker:** mypy in strict mode (`src/wardline` only) +- **Tests:** pytest, run under `pytest-randomly` (order-dependence is a real bug) + +Before committing: + +```bash +make format # auto-fix formatting and lint +make lint # check without modifying (same as CI) +make typecheck # mypy strict +``` + +A ruff pre-commit hook is available — `uv run --with pre-commit pre-commit install`. + +## Running tests + +```bash +make test # quick run +make test-cov # with coverage; CI enforces a 90% floor +``` + +The `network` (live OpenRouter judge) and `clarion_e2e` (real `clarion serve`) +suites are deselected by default. Opt in with `uv run pytest -m network` / +`uv run pytest -m clarion_e2e` (the latter needs a route-capable Clarion binary — +see `CLAUDE.md`). + +## Conventions + +- **TDD.** Write the failing test first. +- Keep PRs focused — one logical change per PR. +- New behavior needs tests. New `wardline.yaml` keys need a `config_schema.py` update. +- No back-compat shims for unreleased specs — make clean changes. +- Wardline scans its own source as a CI gate; keep the tree finding-clean (or baselined). + +## Commit messages + +This project uses [Conventional Commits](https://www.conventionalcommits.org/): + +``` +: +``` + +| Type | When to use | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `test` | Adding or updating tests | +| `ci` | CI/CD pipeline changes | +| `build` | Build system or packaging changes | +| `refactor` | Neither fixes a bug nor adds a feature | +| `style` | Formatting only | +| `chore` | Maintenance (deps, config) | + +Use `!` after the type for breaking changes: `refactor!: rename public API`. + +## Pull request process + +1. Branch from `main`. +2. Make your change (test-first). +3. Run `make ci` until green (ruff check + format check + mypy strict + pytest with the 90% coverage floor). +4. Open a PR against `main`, describing what and why; link related issues. +5. Ensure the CI checks pass. + +## First-time contributors + +Good starting points: documentation improvements, tests for uncovered paths, and +CLI help-text polish. + +## Architecture + +The big-picture developer guide — the L1/L2/L3 taint pipeline, the package map, +and the conventions — lives in [CLAUDE.md](CLAUDE.md). Read it before a +non-trivial change. + +## Code of Conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). + +## License + +By contributing, you agree your contributions are licensed under the [MIT License](LICENSE). +```` + +- [ ] **Step 2: Verify** + +Run: `python -c "open('CONTRIBUTING.md').read().index('Conventional Commits'); print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add CONTRIBUTING.md +git commit -m "docs: expand CONTRIBUTING (uv, conventional commits, PR gate) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: Add ROADMAP + +**Files:** +- Create: `ROADMAP.md` + +- [ ] **Step 1: Write ROADMAP.md** + +```markdown +# Wardline Roadmap + +Wardline is a lightweight, opt-in semantic-tainting analyzer for Python. This is +a direction sketch, not a commitment — dates are deliberately omitted. + +## Where we are + +**0.3.0 — shipped.** The staged build (SP0–SP9) is complete: + +- Function-, variable-, and project-level taint over an inter-module call graph + (L1–L2 with an L3 fixed point). +- The NG-25 trust vocabulary and three opt-in decorators. +- Four policy rules (PY-WL-101..104), severity/enable config, baselines + waivers. +- JSONL + SARIF + native Filigree emit. +- Dependency-free MCP-over-stdio server (`wardline mcp`). +- Opt-in LLM triage judge (`wardline judge`). +- `wardline install` agent enablement. +- Opt-in Clarion taint-store integration. +- Published to PyPI; docs site live; CI dogfoods Wardline on its own source. + +## Scope + +Wardline is deliberately **L1–L2 with an L3 project fixed point**, not an +exhaustive path-sensitive whole-program prover, and Python-only. We favor a +small, precise, opt-in rule set over broad SAST coverage. + +## Near-term threads + +Tracked in the project's Filigree issues: + +- **N-hop `explain_taint` chain completeness** — full boundary-chain reconstruction + on the explain surface (`wardline-82f49ec3c3`). +- **Return-indirection in `compute_return_callee`** — explain-surface completeness + for returns routed through intermediates (`wardline-82f49ec3c3`). +- **Taint-combination hardening** — first-class hardening from the 2026-05-31 + audit (`wardline-2b138b3662`). +- **Star-import decorator markers** — resolve `from x import *` so trust markers + are not missed (`wardline-2b427a9579`). + +## Out of scope (for now) + +- Languages other than Python. +- A general-purpose, dozens-of-rules SAST suite. +- A hosted/cloud service — Wardline stays local-first. +``` + +- [ ] **Step 2: Verify the referenced issues exist** + +Run: `filigree get wardline-82f49ec3c3 2>/dev/null | head -1 || filigree list-issues 2>/dev/null | grep -E "2b138b3662|2b427a9579|82f49ec3c3"` +Expected: the issue IDs resolve. If an ID has changed, update the ROADMAP to match the current open issues before committing. + +- [ ] **Step 3: Commit** + +```bash +git add ROADMAP.md +git commit -m "docs: add ROADMAP (current state, scope, near-term threads) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 10: Expand the Code of Conduct + +**Files:** +- Modify: `CODE_OF_CONDUCT.md` (full replace) + +> Do NOT paste the Contributor Covenant text into this plan (copyright). Fetch the +> canonical Markdown and fill in the contact. + +- [ ] **Step 1: Fetch the canonical Contributor Covenant v2.1 Markdown** + +Obtain the official text from (or `WebFetch` it). It is published under CC BY 4.0; keep its attribution footer intact. + +- [ ] **Step 2: Write `CODE_OF_CONDUCT.md`** + +Write the fetched Contributor Covenant v2.1 verbatim, with the single +enforcement-contact placeholder (`[INSERT CONTACT METHOD]`) replaced by +`john@wardline.dev`. Keep the trailing attribution/links section the license +requires. + +- [ ] **Step 3: Verify** + +Run: `grep -q "john@wardline.dev" CODE_OF_CONDUCT.md && grep -qi "Contributor Covenant" CODE_OF_CONDUCT.md && echo ok` +Expected: `ok`. Confirm the placeholder `[INSERT CONTACT METHOD]` is gone: +`! grep -q "INSERT CONTACT" CODE_OF_CONDUCT.md && echo "placeholder removed"`. + +- [ ] **Step 4: Commit** + +```bash +git add CODE_OF_CONDUCT.md +git commit -m "docs: adopt Contributor Covenant v2.1 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 11: Document the use ⇄ develop split (Audience banner + mirror to AGENTS.md) + +**Files:** +- Modify: `CLAUDE.md` (add Audience banner near the top of the Wardline section) +- Modify: `AGENTS.md` (insert the full Wardline developer guidance above the Filigree block) + +The Wardline developer guidance currently lives only in `CLAUDE.md`, above the +`` block. `AGENTS.md` currently holds ONLY the +Filigree block. This task adds an Audience banner to both and mirrors the +guidance into `AGENTS.md`. + +- [ ] **Step 1: Add the Audience banner to `CLAUDE.md`** + +In `CLAUDE.md`, immediately after the `# CLAUDE.md` header line and its +"This file provides guidance..." line, the existing blockquote about the Filigree +block stays. Add this Audience banner as the FIRST line of the +"## What Wardline is" section (insert immediately before that heading): + +```markdown +> **Audience — developing Wardline.** This file (and `AGENTS.md`) is for people +> and agents changing Wardline itself. End-user "how to *use* Wardline" guidance +> is NOT here — it lives in the `wardline install` instruction block, the +> `wardline-gate` skill (`src/wardline/skills/wardline-gate/SKILL.md`), and the +> docs site. Keep usage guidance out of this file. + +``` + +- [ ] **Step 2: Build `AGENTS.md` = banner + Wardline guidance + Filigree block** + +`AGENTS.md` must become: the same Wardline developer guidance that is in +`CLAUDE.md` (everything from the `# CLAUDE.md` header through the end of the +"## Conventions" section, i.e. everything ABOVE the ``. Call this `WG` (Wardline guidance, now including the Audience banner from Step 1). +2. In that copied text, change the first line from `# CLAUDE.md` to `# AGENTS.md` + and the second sentence from "...when working with code in this repository." + to read "This file provides guidance to coding agents (Claude Code, Codex, + etc.) when working with code in this repository." (Adjust the existing + blockquote note that currently says "AGENTS.md is the byte-identical Filigree + twin of this file; the Wardline guidance here is not mirrored there." — it is + now inaccurate. Replace that sentence in BOTH files with: "Its twin + `CLAUDE.md`/`AGENTS.md` carries the same Wardline guidance; the Filigree block + below is auto-managed in each.") +3. Read the current `AGENTS.md` and copy the Filigree block (from + `` through + ``). Call this `FB`. +4. Write `AGENTS.md` = `WG` + `\n` + `FB` + `\n`. + +- [ ] **Step 3: Verify both files** + +Run: +```bash +grep -q "Audience — developing Wardline" CLAUDE.md && echo "claude banner ok" +grep -q "Audience — developing Wardline" AGENTS.md && echo "agents banner ok" +grep -q "## What Wardline is" AGENTS.md && grep -q "filigree:instructions" AGENTS.md && echo "agents has both sections" +head -1 AGENTS.md # expect: # AGENTS.md +# Confirm the stale "not mirrored there" sentence is gone from both: +! grep -q "not mirrored there" CLAUDE.md && ! grep -q "not mirrored there" AGENTS.md && echo "stale note removed" +``` +Expected: all four `echo`s print, `head -1` shows `# AGENTS.md`, and "stale note removed" prints. + +- [ ] **Step 4: Confirm the Filigree block is byte-identical in both (markers intact)** + +Run: +```bash +sed -n '//p' CLAUDE.md > /tmp/fb_claude +sed -n '//p' AGENTS.md > /tmp/fb_agents +diff /tmp/fb_claude /tmp/fb_agents && echo "filigree block identical" +``` +Expected: `filigree block identical`. + +- [ ] **Step 5: Commit** + +```bash +git add CLAUDE.md AGENTS.md +git commit -m "docs: audience banner + mirror developer guidance into AGENTS.md + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 12: Final verification gate + +**Files:** none (verification only) + +- [ ] **Step 1: Clean-room sync + full local CI** + +Run: +```bash +rm -rf .venv +uv sync --all-extras --group dev +make ci +``` +Expected: sync succeeds from clean; `make ci` is green (ruff check + format check + mypy strict + pytest `1001 passed` + coverage ≥90%). + +- [ ] **Step 2: Build + package integrity** + +Run: +```bash +make build +uv run --with twine twine check dist/* +``` +Expected: sdist + wheel built; `twine check` PASSED for both. + +- [ ] **Step 3: Docs strict build** + +Run: `uv run mkdocs build --strict` +Expected: builds with zero warnings. + +- [ ] **Step 4: Dogfood scan still clean** + +Run: `make scan-self` +Expected: exit 0 (no active defect ≥ ERROR in `src/wardline`). + +- [ ] **Step 5: Workflow YAML lint** + +Run: +```bash +python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); yaml.safe_load(open('.github/workflows/release.yml')); print('workflows ok')" +``` +Expected: `workflows ok`. + +- [ ] **Step 6: Push the branch and open a PR** + +```bash +git push -u origin docs/repo-standardization +gh pr create --title "Bring repo up to the filigree standard" --body "$(cat <<'EOF' +Implements docs/superpowers/specs/2026-06-01-wardline-repo-standardization-design.md. + +- uv toolchain (dependency-groups, uv.lock, .python-version); build stays hatchling +- CI: 3.12/3.13 matrix + ruff format check + 90% coverage floor; dogfood/network/docs jobs preserved +- release.yml builds with `uv build`; PyPI Trusted Publishing job unchanged +- Makefile + ruff pre-commit +- filigree-class README; expanded CONTRIBUTING; ROADMAP; Contributor Covenant +- Audience banner + developer guidance mirrored into CLAUDE.md and AGENTS.md +- No engine/source behavior changes (formatting-only churn from `ruff format`) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` +Expected: PR opens against `main`; remote CI runs green. + +--- + +## Self-Review + +**Spec coverage:** +- Use ⇄ develop split → Task 11 (banner + AGENTS mirror); user surfaces left untouched (stated, no task — correct). +- README → Task 7. uv migration → Task 1. CI hardening → Task 5. release `uv build` + PyPI preserved → Task 6. Makefile → Task 3. pre-commit → Task 4. CONTRIBUTING → Task 8. ROADMAP → Task 9. CoC → Task 10. `ruff format --check` precondition → Task 2. Final verification → Task 12. All spec sections covered. + +**Placeholder scan:** No "TBD/TODO". The only intentional non-inline content is the Contributor Covenant body (Task 10) — copyright; the plan instructs fetching it and gives exact verification. All config/doc files have complete content. + +**Type/name consistency:** Canonical dev command is `uv sync --all-extras --group dev` everywhere (Tasks 1, 3, 5, 6, 8, 12, README). Coverage floor `--cov-fail-under=90` consistent (Tasks 3, 5, 8, 12). `[dependency-groups].dev` (Task 1) matches every `uv sync` invocation. Job name `test` referenced by `needs: test` (Task 5) matches the defined job. README badges/links use `foundryside-dev/wardline` and `pypi.org/project/wardline` consistently. diff --git a/docs/superpowers/specs/2026-05-31-wardline-030-return-indirection-scope.md b/docs/superpowers/specs/2026-05-31-wardline-030-return-indirection-scope.md new file mode 100644 index 00000000..8977eaf7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-wardline-030-return-indirection-scope.md @@ -0,0 +1,648 @@ +# Scope — 0.3.0: resolve return indirection in `compute_return_callee` + +**Issue:** wardline-82f49ec3c3 (feature, P3) — the **only open child** of epic +wardline-2b138b3662 ("Taint-combination engine — first-class hardening"). +**Branch:** `release/0.3.0`. +**Date:** 2026-05-31. + +--- + +## 0. Headline: the epic is already 90% shipped + +The epic has 10 children. **Nine are closed/done** and already in `main`, +shipped as part of `0.2.1` (PR #13, `feat(taint): first-class hardening of the +taint-combination engine`): + +| Child | Verdict | +|---|---| +| F5 — gate the two ungated `TaintState(...)` parsers | done | +| F2 — remove dead unresolved-clamp in SCC round | done | +| F6 — fix stale `keep taint_join` comment | done | +| F1+F3 — lattice disposition (decided **RETAIN**, ADR written) | done | +| F1 — document MIXED_RAW-unreachable invariant | done | +| Invariant-enforcement property test (reachable-set closure) | done | +| Fault-injection tests for defensive propagation branches | done | +| F4 — document present-but-wrong-predicate validator blind spot | done | +| Authoritative taint-algebra & combination-semantics design doc | done | + +**The one remaining child is wardline-82f49ec3c3.** So "scope the epic for +0.3.0" reduces to scoping this single P3 explain-surface feature. There is no +other open work under the epic. + +--- + +## 1. What the feature is + +`compute_return_callee` (`src/wardline/scanner/taint/variable_level.py:929`) +names the callee that contributes a function's *actual* (least-trusted) return +taint — the value surfaced to agents as `immediate_tainted_callee` (MCP +`explain_taint`) and folded into the PY-WL-101 `via_callee` taint-path string. + +Today it only names a callee when the least-trusted return path's top-level +expression is a **direct `ast.Call`** (`_return_callee`, line 967). For an +**indirect** return — `return some_var` where `some_var` was tainted by an +earlier call — it returns `None`. The verdict is still correct (the finding +fires); the provenance is just incomplete: "tainted sink, no named source." + +This feature closes that gap **single-hop, in-process**. + +### Decided framing (not open for re-litigation) + +The issue text fixes the direction; do **not** present these as choices: + +- **Single-hop only.** N-hop chain walking stays Clarion's job — `explain_chain` + (`core/explain.py:190`) already walks N hops over the *stored-fact* path via + `contributing_callee_qualname`. This feature feeds that path a better hop-0. +- **Explain-only.** No fire/no-fire change. `compute_return_taint` VALUES + (line 899) are untouched — only `compute_return_callee` (the diagnostic + sibling) gains resolution power. +- **Build it.** The "do we build / defer to SP9" question is settled. + +--- + +## 2. The decision that WAS open — and the answer + +To name the callee for `return some_var`, we need to know **which callee set +`some_var` to its taint value**. `var_taints` (`dict[str, TaintState]`, +variable_level.py:83) stores only the *final taint state* per name — provenance +was deliberately dropped in the wardline.old → rebuild port (module docstring, +lines 2–14). So the callee-of-a-variable is not currently recoverable. + +**Two strategies were possible; the user chose merge-precise.** + +> **DECISION (user, 2026-05-31): merge-precise via a threaded provenance map.** +> Indirect returns whose least-trusted variable was set across a control-flow +> merge (if/else, try/except, loop back-edge, match arm) MUST name the surviving +> branch's callee — not degrade to `None`. + +Rejected alternative (localized backward search inside `compute_return_callee`): +lower blast radius, but precise only for straight-line `x = call(); return x`; +degrades to `None` on any control-flow-merged var because it cannot know which +branch's value survived the merge without replaying it. + +--- + +## 3. Design — threaded `var_callee` provenance map + +### Internal state object + +Introduce a private `_L2State` dataclass that bundles the two maps: + +```python +@dataclass +class _L2State: + taints: dict[str, TaintState] + callees: dict[str, str | None] +``` + +All internal walker functions (`_seed_parameters`, `_handle_assign`, …, +`_handle_match`) take `_L2State` instead of two separate dicts. This eliminates +parallel-dict drift: a future write-site addition that forgets `callees` will +produce a type error, not a silent wrong result. + +`compute_variable_taints` returns `_L2State`. Its callers in `analyzer.py` +extract `.taints` and `.callees` to pass separately to +`compute_return_callee(var_taints, var_callee, …)`. The public type of +`function_return_callee` (downstream of `compute_return_callee`) is unchanged. + +`var_callee` records, per variable name, **the callee name that contributed that +variable's current (least-trusted-so-far) taint**, or `None` when no resolvable +callee is in scope. + +### Callee attribution rule for assignments + +For any assignment `target = ` (applies at every write site): + +| RHS shape | `state.callees[target]` | +|---|---| +| `ast.Call` with resolvable name | callee name via `_return_callee(expr)` | +| `ast.Name` (alias: `y = x`) | `state.callees.get(rhs.id)` — propagate provenance | +| anything else (literal, subscript, attr, …) | `None` | + +Name-copy propagation (`y = x`) is the **zero-extra-hop alias** case. `y`'s +taint IS `x`'s taint, so `y`'s provenance is `x`'s provenance. This is not +multi-hop resolution; the "single-hop" boundary refers to `compute_return_callee` +not walking further than one `var_callee` lookup, not to the number of alias +copies tracked. + +### The merge rule + +`least_trusted` keeps the worst (least-trusted) taint. At a merge the surviving +callee is: **the first source-order worst-rank branch that has a non-`None` +callee; or `None` if every worst-rank branch has a `None` callee.** + +This matches `compute_return_callee`'s existing consumer logic exactly (the loop +already skips `None` callees and picks the first non-`None` worst-rank entry). +It is the **opposite** of `minimum_scope.py:164`'s `max(..., key=TRUST_RANK)` +(which selects the most-trusted callee for L3 aggregation) — do not copy that +direction. + +When every branch has a non-call RHS (parameter, literal, merged non-call +values), `var_callee[x]` is `None`. Graceful degradation is preserved. + +### Resolution in `compute_return_callee` + +`_collect_return_paths` already records `(taint, direct_callee_or_None)` per +return. Extend it so that when the chosen least-trusted path is `return ` +and its direct callee is `None`, fall back to `state.callees[]`. Subscript, +attribute, and other non-`Name` returns are **out of single-hop scope** — they +stay `None`; state this in the docstring. The least-trusted-path-selection logic +is unchanged; only the callee-naming gains the `var_callee` fallback. + +--- + +## 4. Touch points (file:line) + +**Allocate and return state:** + +- `variable_level.py:63-86` `compute_variable_taints` — allocate `_L2State`, + call `_seed_parameters` to seed `taints` and `callees` (`None` per param), + thread `state` through the body walk. Return type changes to `_L2State`; + `analyzer.py` callers extract `.taints` and `.callees`. + +**Write sites — complete list. Every line that sets `state.taints[x]` must also +set `state.callees[x]` using the attribution rule (§3):** + +- `:89-100` `_seed_parameters` — `state.callees[name] = None` for every param; + parameter bindings carry no direct-call provenance. +- `:490` `_handle_assign` — `x = expr`: attribution rule (§3); for `ast.Name` + RHS propagate `state.callees.get(rhs.id)`. +- `_process_stmt` `AnnAssign` branch — annotated `x: T = expr` uses the same + attribution rule; bare `x: T` (no value) writes neither map. +- `_assign_target` (called by `for` loop target binding, `match` capture, and + starred/nested unpack) — callee is context-dependent; see table below. +- `:525` `_handle_unpack` — element-wise when arity matches a tuple/list literal: + `state.callees[target_i]` from the attribution rule applied to `elements[i]`. + Whole-RHS rule when arity doesn't match or RHS is not a literal: attribution + rule on the whole RHS expression for each target. `Starred` targets: `None`. +- `:560` `_handle_augassign` — `x += expr`: new callee is the attribution-rule + callee of the worse-rank side. On equal rank keep the **existing** callee + (left-wins). If the winning side has no callee: `None`. +- `:157-161` `_resolve_expr` `NamedExpr` — walrus `x := expr`: attribution rule. + `_resolve_expr` must receive `state` so the walrus side-effect writes the + correct scope's `callees`. +- `_walk_exprs_for_walrus` / `_resolve_comprehension` — comprehension walrus + expressions that leak into the enclosing scope must propagate via the enclosing + `state.callees`. Confirm all callers of these helpers pass `state`. +- `_handle_with` — `with expr as x:` binding: attribution rule on the context + manager expression. +- `_taint_container_base` (`:579-596`) — `d[k] = expr` updates the container + base's taint; update `state.callees[base_name]` with the attribution-rule + callee of the RHS. +- `except … as e` — exception-handler binding: `state.callees[e] = None` + (exception object is not the result of a direct call in the catch handler). + +**`_assign_target`-mediated callee rules:** + +| Binding context | `state.callees` rule | +|---|---| +| `for x in iterable:` | `None` (loop-iteration target; element is not a direct call) | +| `match subject: case x:` | `state.callees.get(subject.id)` if subject is `ast.Name`; `_return_callee(subject)` if `ast.Call`; else `None` | +| Nested / starred unpack via `_assign_target` | inherit from matched element (same element-wise rule as `_handle_unpack`) | + +**Merge sites — apply the merge rule (§3) alongside each `least_trusted` combine:** + +- `:642` `_handle_if` +- `:678` `_handle_for` — includes back-edge merge at loop exit (pre-loop state + vs loop-body state); apply merge rule in the back-edge as well. +- `:708` `_handle_while` — same back-edge merge. +- `:778` `_handle_try` +- `:842` `_handle_match` + +**Consume the map:** + +- `:977-1006` `_collect_return_paths` / `:929` `compute_return_callee` — add + `var_callee` fallback for `return ` least-trusted paths. Signature: + `compute_return_callee(var_taints, var_callee, …)`. + +**Downstream — verify the ripple; no code change except where noted:** + +- `analyzer.py:250` — **walrus isolation (code change required)**: copy + `state.callees` before passing to `compute_return_callee`, mirroring the + existing `var_taints` copy (walrus expressions must not mutate the post-walk + state during return-path resolution). +- `analyzer.py:250` builds `function_return_callee` from `compute_return_callee` + → flows unchanged through `context.py:48` → `core/explain.py:84`. +- `core/explain.py:88-106` `source_boundary_qualname` — **user-visible API + change**: every `explain_taint` call on a previously-`None` indirect return + will now return a `source_boundary_qualname`. Desirable; requires both a + positive and a negative test (see §5 tests 7 and 8). +- `clarion/facts.py:80` `contributing_callee` now populated for indirect returns + → `explain_chain` gets a real hop-0. No code change. + +--- + +## 5. Verification plan (the done-signal) + +Existing tests assert the **current `None`** behavior and will **invert** — that +inversion is the concrete acceptance signal: + +- `tests/unit/scanner/taint/test_variable_level.py:390` — + `x = read_raw(p); return x` currently asserts `None`; flips to `"read_raw"`. + (Line :391 `return p` (bare parameter) stays `None` — no contributing call.) +- `tests/unit/core/test_explain.py:68-78` + `test_explain_non_call_return_has_no_immediate_callee` — currently asserts + `immediate_tainted_callee is None` for a bare-variable return; flips. (Keep a + *truly* sourceless case — e.g. `return p` or `return 1` — asserting `None`, so + graceful degradation stays covered.) + +New tests to add: + +1. **Merge precision** — `if c: x = read_raw(p); else: x = svc.get(p); return x` + → names the least-trusted branch's callee. Variants: try/except, match arm. +2. **Loop back-edge merge** — for-loop and while-loop: a call inside the loop + sets the least-trusted taint; the loop-exit merge must survive: + ```python + x = safe_val + for _ in items: + x = read_raw(p) + return x # expected: "read_raw" + ``` +3. **Equal-rank tie-break** — two branches of equal rank; first source-order + non-`None` callee wins: + ```python + if c: + x = read_a(p) + else: + x = read_b(p) + return x # expected: "read_a" + ``` +4. **Mixed direct-return + indirect-return paths** — indirect path must compete + correctly with direct-return paths: + ```python + if c: + return validate(p) # direct, worst rank + x = read_raw(p) + return x # indirect — expected: "read_raw" + ``` +5. **Name-copy (alias) propagation** — provenance follows the value: + ```python + x = read_raw(p) + y = x + return y # expected: "read_raw" + ``` +6. **Subscript / attribute out-of-scope** — `return d[k]` and `return obj.attr` + both stay `None` (graceful degradation boundary). +7. **`source_boundary_qualname` positive** — indirect return whose callee is a + same-module leaf source now reports the boundary qualname (was `None`). +8. **`source_boundary_qualname` negative** — callee is NOT a same-module leaf + source; boundary must stay `None`: + ```python + x = helper(p) # helper calls read_raw internally — not a leaf source + return x # expected: source_boundary_qualname = None + ``` +9. **`_handle_unpack` element-wise callee**: + ```python + a, b = safe_call(), read_raw(p) + return b # expected: "read_raw" + ``` +10. **`_handle_augassign` callee update** — RHS wins on worse rank: + ```python + x = safe_call() + x += read_raw(p) + return x # expected: "read_raw" + ``` +11. **Walrus callee attribution**: + ```python + if (x := read_raw(p)): + return x # expected: "read_raw" + ``` +12. **Fire-set invariance** (precise corpus definition): assert + `compute_return_taint` values and the PY-WL-101 fire set are byte-identical + before/after on: (a) oracle battery fixture module(s); (b) `src/wardline` + self-host. Comparison keys: sorted `(rule_id, fingerprint, path, line, + qualname, declared_return, actual_return)` — byte-identical. Explanation + fields (`via_callee`, `immediate_tainted_callee`) are expected to change and + are excluded from this comparison. + +**Gates** (project standard, matches how the 9 sibling children shipped): full +`pytest` green, `ruff`/`mypy` clean, `mkdocs build --strict`, oracle battery +30/30, self-host 0 PY-WL defects. + +--- + +## 6. Boundaries (state in PR + docstrings) + +- **Single-hop only.** Multi-hop indirection (`x = read_raw(); y = x; return y`) + resolves at most one hop in-process; full chains are Clarion's `explain_chain`. +- **No fire/no-fire change.** `compute_return_taint` untouched; verified by test 4. +- **Subscript/attribute returns out of single-hop scope** — `return d[k]` / + `return obj.attr` stay `None`. +- **Update** `compute_return_callee` + `context.py:37` docstrings and CHANGELOG + `[Unreleased]` (the "indirection deferred to SP9" notes are now partially + resolved — reword, don't delete the multi-hop caveat). +- **`source_boundary_qualname` is a user-visible contract change**, not an + internal ripple. Every `explain_taint` call on a previously-`None` indirect + return will now return a value. Callers that previously pattern-matched on + `source_boundary_qualname is None` to detect "no boundary" must still work + correctly (the negative case — non-leaf-source callee — still returns `None`). + State this explicitly in the PR description and the `source_boundary_qualname` + docstring. +- **`analyzer.py` walrus isolation** — the existing copy of `var_taints` before + `compute_return_callee` must be extended to also copy `var_callee`. Both copies + together prevent walrus side-effects from leaking into return-path resolution. + +--- + +## 7. Effort + +Medium. The structural work is larger than the original "small-medium" estimate: +`variable_level.py` requires `_L2State` introduction + a return-type change to +`compute_variable_taints` + ~14 write sites + 5 merge-site callee rules + 2 +walrus threading paths. The consume-side fallback in `compute_return_callee` is +small. Downstream is ripple-only except the `analyzer.py` walrus isolation copy. +Tests: 12 new cases (2 inversions + 10 new). The risk is concentrated in the +merge sites inside the fire/no-fire-critical L2 transfer functions — test 12 +(fire-set invariance) is the guard. Recommend subagent-execute + +adversarial-review pattern given that proximity. + +--- + +## 8. Pre-implementation blockers (panel review 2026-06-01) + +**Status: RESOLVED** — all items below have been incorporated into §§3–6 above. +The section is retained as the audit trail of panel findings. No further spec +changes are required before implementation. Raised by a five-panel review +(Architecture Critic, Systems Thinker, Python Engineer, Static Analysis +Engineer, Quality Engineer) on 2026-06-01; resolved same day. + +--- + +### 8.1 Critical — resolve in this spec before handing off + +#### BLOCKER-1: write-site list is incomplete + +Section 4 lists ~9 write sites, but `var_taints` is mutated in more places. +Every one of the following also writes `var_taints` and MUST also write +`var_callee` in lockstep, or the two maps will silently drift: + +| Site | Location | +|---|---| +| `_seed_parameters` | `:89-100` — parameters must seed `var_callee[name] = None` | +| `AnnAssign` branch | inside `_process_stmt` | +| `_assign_target` | called by `for` target binding, `match` capture binding, and any starred/nested unpack | +| `_walk_exprs_for_walrus` | comprehension walrus leakage back to enclosing scope | +| `_handle_with` target binding | `with expr as x:` | +| `_taint_container_base` | `d[k] = call()` / `obj.x = call()` updates the base var's taint | +| `except … as e` binding | exception handler name binding | +| `match` capture binding | `case x:` — see also BLOCKER-3 | + +**Action:** audit every `var_taints[...] =` write in `variable_level.py` to +produce the complete, authoritative list and replace section 4 with it before +implementation begins. + +--- + +#### BLOCKER-2: name-copy propagation is under-specified + +The spec says `_handle_assign` sets `callee = _return_callee(expr)`. For a +Name RHS (`y = x`) `_return_callee` returns `None`, so: + +```python +x = read_raw(p) # var_callee[x] = "read_raw" +y = x # var_callee[y] = None ← spec as written +return y # returns None ← wrong +``` + +If `var_callee` means "callee that contributed the variable's current taint," +plain name copies MUST propagate provenance — `y`'s taint IS `read_raw`'s +taint. Calling this "multi-hop" is incorrect; it is a zero-extra-hop alias. + +**Action — choose one option and state it explicitly in this spec:** + +- **(A) Propagate name copies (recommended):** when the RHS of an assignment is + `ast.Name`, set `var_callee[target] = var_callee.get(rhs.id)`. Document this + as the behaviour. Rename the scope label from "single-hop" to + "variable-provenance resolution" to avoid the ambiguity. +- **(B) Explicitly exclude name copies:** keep `_return_callee(expr)` only. + Rename the feature from "single-hop" to **"direct-call RHS only"** and + document that `y = x; return y` stays `None`. The boundary must be stated in + the PR description and the docstring. + +Whichever option is chosen, it must also be reflected in the test plan +(§8.3 TEST-1 below). + +--- + +#### BLOCKER-3: tie-break rule does not match `compute_return_callee` + +Section 3 states the merge rule as "min-by-rank, source-order tie-break." That +is not what `compute_return_callee` actually does. The current consumer is: + +```python +for taint, callee in returns: + if taint == worst and callee is not None: # skips None callees + return callee +``` + +It picks the **first source-order worst-rank path that has a non-`None` +callee**. A pure source-order tie-break picks the first worst-rank branch +regardless of whether it has a callee — which can return `None` where the +consumer would return a name. Example: + +```python +if c: + x = p # EXTERNAL_RAW, callee=None ← if-branch (first) +else: + x = read_raw(p) # EXTERNAL_RAW, callee="read_raw" +return x +``` + +Under the spec's tie-break: `var_callee[x] = None`. +Under the consumer's logic on equivalent direct returns: `"read_raw"`. + +**Action:** Replace the merge tie-break rule in section 3 with: + +> At a merge, the surviving callee is: **first source-order worst-rank branch +> with a non-`None` callee; or `None` if all worst-rank branches have `None` +> callees.** + +This matches the consumer exactly and eliminates the asymmetry. + +--- + +### 8.2 Important — resolve before merge (may be addressed during implementation) + +#### RESOLVE-4: `_handle_augassign` exact callee rule + +"Merge old+new; least-trusted rule" (section 4) is ambiguous. The spec must +state: + +> For `x += expr`: new taint = `least_trusted(existing, rhs)`. +> New callee = callee of whichever side produced the worse taint. +> On equal rank, keep the **existing** callee (left-wins, consistent with +> `least_trusted`'s tie policy). +> If the winning side has no callee (non-direct-call expression), set +> `var_callee[x] = None`. + +#### RESOLVE-5: `_handle_unpack` callee semantics + +The spec lists `_handle_unpack` as a write site but does not specify callee +attribution. Required behaviour: + +- **Element-wise match** (`a, b = call_a(), call_b()`): `var_callee[a] = + "call_a"`, `var_callee[b] = "call_b"`. +- **Non-matching RHS** (all targets get RHS taint): `var_callee[tgt] = + _return_callee(rhs_expr)` for each target. +- **Nested unpack and `Starred`**: follow the same rule recursively (callee + from the matched element, or whole-RHS callee when element is unavailable). + +#### RESOLVE-6: consider a private state object instead of two parallel dicts + +Threading two raw dicts through ~14 function signatures is structurally fragile: +a future write-site addition that updates `var_taints` but not `var_callee` will +silently produce wrong provenance. Consider a private internal state object: + +```python +@dataclass +class _L2State: + taints: dict[str, TaintState] + callees: dict[str, str | None] +``` + +The public API of `compute_variable_taints` (returns `dict[str, TaintState]`) +and `compute_return_callee` (takes `var_taints`) stays unchanged. The private +walkers all take `_L2State` instead of two separate dicts. This is a "should" +not a "must" — the parallel-dict approach works if BLOCKER-1 is fully resolved — +but it eliminates the drift risk structurally. + +#### RESOLVE-7: `source_boundary_qualname` is a user-visible change, not just a ripple + +The "auto-extends" framing understates the impact: every MCP `explain_taint` +call on an indirect-return sink that previously returned +`source_boundary_qualname=None` will now return a value. This is desirable, but: + +- It must be treated as an **API contract change** for the explain surface and + Clarion facts. +- It requires both a **positive** test (indirect return → leaf-source boundary + reported) and a **negative** test (indirect return where the callee is NOT a + same-module leaf source → boundary stays `None`). The spec currently only + mentions a positive test. + +#### RESOLVE-8: `match` capture pattern callee inheritance + +For `case x:` binding from `match some_var:`, the spec says `var_callee` is +written via `_assign_target` (BLOCKER-1 above), but it does not specify what +value to write. The required semantics: + +> A `match` capture target `x` inherits `var_callee[some_var]` if the subject +> is an `ast.Name`, or `_return_callee(subject_expr)` if the subject is a +> direct call, or `None` otherwise. + +This is consistent with how `var_taints` inherits the subject's taint. + +#### RESOLVE-9: walrus threading is deeper than listed + +The spec lists `:157-161` `_resolve_expr` NamedExpr as the walrus write site. +But walrus can also leak through `_walk_exprs_for_walrus` and comprehension +bodies (`_resolve_comprehension`). After the change, `_resolve_expr` needs +access to `var_callee` — which propagates into its recursive callers. The full +set of `_resolve_expr` callers that can produce walrus side effects must be +identified and confirmed to carry `var_callee`. + +Additionally: `analyzer.py` currently makes a copy of `var_taints` before +passing it to `compute_return_callee` to isolate walrus side effects during +return-walk resolution. The same isolation must apply to `var_callee`. + +--- + +### 8.3 Required additional tests (beyond section 5) + +The original verification plan (section 5) is a good backbone but is missing +the following. All items marked **[critical]** must be present before the +feature can be declared done. + +#### TEST-1 [critical]: for- and while-loop back-edge merge + +```python +# for +x = safe_val +for _ in items: + x = read_raw(p) +return x # expected: "read_raw" + +# while +x = safe_val +while cond: + x = read_raw(p) +return x # expected: "read_raw" +``` + +Why: loop back-edge merge (`body` vs `pre_loop`) has different semantics from +branch alternatives (`if`/`try`/`match`). Bugs in the loop merge sites can hide +while all branch-merge tests pass. + +#### TEST-2 [critical]: equal-rank tie-break pinned + +```python +if c: + x = read_a(p) # both callees mapped to same taint tier +else: + x = read_b(p) +return x # expected: "read_a" (first source-order with non-None callee) +``` + +Why: the tie-break rule is a central promise of the design (and was incorrect in +the original spec — BLOCKER-3). Without this test the rule is not pinned. + +#### TEST-3 [critical]: mixed direct-return + indirect-return paths + +```python +if c: + return validate(p) # direct call, less-trusted-than: +x = read_raw(p) +return x # indirect — expected: "read_raw" +``` + +Why: indirect-return provenance must compete correctly with direct-call return +paths. The least-trusted path must win regardless of whether it is direct or +indirect. + +#### TEST-4 [critical]: invariance corpus must be defined precisely + +The "small corpus" for fire-set invariance (original test 4) must be: +1. The oracle battery fixture module(s). +2. `src/wardline` self-host. +3. The exact comparison keys: sorted + `(rule_id, fingerprint, path, line, qualname, declared_return, actual_return)` + must be byte-identical before and after. Explanation fields (`via_callee`, + `immediate_tainted_callee`) are expected to change and are not in this set. + +#### TEST-5 [critical]: negative `source_boundary_qualname` + +```python +# helper is NOT a leaf source — it calls read_raw internally +x = helper(p) +return x # expected: source_boundary_qualname = None +``` + +Why: positive-only testing can hide over-attribution where any same-module +callee is incorrectly reported as a boundary. + +#### TEST-6 [important]: `_handle_unpack` element-wise callee + +```python +a, b = safe_call(), read_raw(p) +return b # expected: "read_raw" +``` + +#### TEST-7 [important]: `_handle_augassign` callee update + +```python +x = safe_call() +x += read_raw(p) # read_raw produces worse taint +return x # expected: "read_raw" +``` + +#### TEST-8 [important]: walrus callee attribution + +```python +if (x := read_raw(p)): + return x # expected: "read_raw" +``` + +#### TEST-9 [important]: `return obj.attr` graceful degradation boundary + +```python +return obj.attr # expected: None (attribute returns out of scope) +``` diff --git a/docs/superpowers/specs/2026-06-01-wardline-repo-standardization-design.md b/docs/superpowers/specs/2026-06-01-wardline-repo-standardization-design.md new file mode 100644 index 00000000..9447d7ec --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-wardline-repo-standardization-design.md @@ -0,0 +1,202 @@ +# Wardline repo standardization — design + +**Date:** 2026-06-01 +**Status:** approved (brainstorming → ready for plan) +**Branch:** `docs/repo-standardization` + +## Problem + +Wardline's engine, docs site, and test suite are first-class, but the *repo +presentation* lags its sibling [filigree](/home/john/filigree): the README is a +9-line pointer, CI is single-Python with no coverage gate, there is no Makefile, +pre-commit, or ROADMAP, and CONTRIBUTING is a stub. The project "feels like an +afterthought" next to filigree. + +A second, equally important problem: there is no crisp line between **how to +*use* Wardline** (guidance that ships into every user's agent context) and **how +to *develop* Wardline** (guidance that belongs only in this repo). The two must +not blur. + +## Goals + +1. Bring the repo up to the filigree standard: rich README, fuller CONTRIBUTING, + ROADMAP, Makefile, pre-commit, hardened CI, full uv toolchain parity. +2. Establish and document the **use ⇄ develop** split so each surface has one + unambiguous home. +3. Change **no engine/source behavior** and **no analyzer features**. This is a + presentation + tooling + docs pass. + +## Non-goals + +- No changes to `src/wardline/` engine, scanner, rules, or CLI behavior. +- No restructure of the mkdocs doc site (`docs/concepts`, `docs/guides`, + `docs/reference`). The README *surfaces* those pages; it does not replace them. +- No change to the *wording* of the install-injected instruction block + (`install/block.py` `_BODY`) — only its role is documented. (Note: editing + `_BODY` would change its body hash and the `inject_block` fence; deliberately + avoided here.) + +## The use ⇄ develop split + +This is the organizing principle, not a side note. + +| Audience | Surface | Owns | +|---|---|---| +| **Users** (agents armed with Wardline in *their* projects) | The `wardline install` instruction block (`install/block.py`), the `wardline-gate` skill (`src/wardline/skills/wardline-gate/SKILL.md`), and `docs/agents.md` | The scan → explain → fix-at-boundary → rescan loop; baseline-vs-waiver discipline; exit codes; CLI vs MCP usage. | +| **Developers** (anyone changing Wardline itself) | This repo's `CLAUDE.md` and `AGENTS.md` | Dev commands (uv/make), the L1/L2/L3 architecture, package map, conventions, error model. | + +**Actions:** + +- `CLAUDE.md` already carries the developer guidance (added 2026-06-01). Add a + one-line **Audience** banner near the top: *"This file is for developing + Wardline. End-user 'how to use Wardline' guidance lives in the + install-injected instruction block, the `wardline-gate` skill, and the docs + site — not here."* +- **Mirror** the developer guidance into `AGENTS.md` (above the auto-managed + Filigree block, same as filigree treats its `AGENTS.md`), with the same + Audience banner. The two files carry identical Wardline guidance; the Filigree + block below the markers stays as-is in both. +- The user-facing surfaces are left functionally unchanged; the spec records + them as canonical so future edits land in the right place. + +## Deliverables + +### 1. README.md (replace the 9-line stub) + +Target ~150–180 lines, filigree-class. Sections, in order: + +- Title + one-line tagline + badges: CI status, PyPI version, supported + Python versions, license (shields.io, pointed at + `github.com/foundryside-dev/wardline` and `pypi.org/project/wardline`). +- **Hero block.** Wardline has no GUI, so in place of filigree's dashboard + screenshot, show a short real terminal session: a decorated source snippet and + the resulting `PY-WL-101` finding + gate exit. (Use the canonical + `read_request`/`build_record` example from `docs/concepts/model.md` so it + stays consistent with the docs.) +- **What is Wardline?** Trust-tainting in two sentences; part of the **Loom** + suite (Wardline + Clarion + Filigree); the opt-in / zero-dependency-base + stance. +- **Key Features** (bullets): deterministic whole-program taint; opt-in trust + decorators; four policy rules; zero-dep base + extras; SARIF + JSONL + native + Filigree emit; dep-free MCP-over-stdio server; optional LLM triage judge; + baseline/waiver suppression; `wardline install` agent enablement; Clarion + taint-store integration. +- **Quick Start**: `pip install wardline[scanner]`, decorate a boundary + + producer, `wardline scan . --fail-on ERROR`, read the finding. +- **Installation** + extras table (`scanner`, `clarion`, `docs`; base is + zero-dep; judge needs no extra). +- **Agent setup**: `wardline install` (what it wires), the `wardline-gate` + skill, `wardline mcp`. +- **Where Wardline fits / When NOT to use it**: L1–L2 taint, not full L3 + interprocedural everything; Python ≥3.12 only; opt-in (silent on undecorated + code by design); not a replacement for a SAST suite. Honest framing, no + combative feature-matrix against Bandit/Semgrep/CodeQL. +- **Documentation** table linking the live mkdocs pages + (getting-started, concepts/model, concepts/rules, guides/configuration, + guides/suppression, guides/judge, guides/clarion-taint-store, reference/cli, + reference/vocabulary, agents). +- **Development**: `uv sync --all-extras --group dev`; `make ci`; pointer to + CONTRIBUTING and CLAUDE.md. +- **License** (MIT) + **Acknowledgements** (the Loom siblings). + +### 2. uv toolchain migration (full filigree parity) + +- Convert the `dev` optional-dependency to a PEP 735 `[dependency-groups].dev` + containing pure tooling: `pytest`, `pytest-cov`, `pytest-randomly`, `ruff`, + `mypy`, `types-PyYAML`, `types-jsonschema`. (The current `dev` extra's + self-references `wardline[scanner]`/`wardline[clarion]` do not translate to + dependency-groups; runtime deps come from the extras instead — see below.) +- Keep runtime extras `scanner` / `clarion` / `docs` as + `[project.optional-dependencies]` (the source of truth for runtime deps). +- Canonical dev sync everywhere (Makefile, CI, CONTRIBUTING): + **`uv sync --all-extras --group dev`** — installs base + scanner + clarion + + docs + dev tooling, so tests that need pyyaml/jsonschema/click/blake3 and the + docs build all have what they need. +- Build backend stays **hatchling** (uv only manages the environment/lockfile). +- Add `uv.lock` (generated via `uv lock`) and `.python-version` = `3.13`. +- Add `[tool.coverage.run] source = ["wardline"]` and a `[tool.coverage.report]` + block to pyproject; do **not** add `--cov-fail-under` to default pytest + `addopts` (keeps bare `pytest`/`uv run pytest` fast — the floor lives in the CI + test job and `make test-cov`). +- `release.yml`: replace the `pip install build` + `python -m build` step with + `uv build`. **The publish job is unchanged** — `pypa/gh-action-pypi-publish` + with PyPI Trusted Publishing (id-token) stays exactly as is. Packages still + ship to PyPI. + +### 3. CI hardening (`.github/workflows/ci.yml`) + +- Add `concurrency: { group: ..., cancel-in-progress: true }`. +- Switch installs to `astral-sh/setup-uv` (with cache) + `uv sync --all-extras + --group dev`. +- Split the single `gate` job into: + - **lint** — `uv run ruff check src tests` + `uv run ruff format --check src tests`. + - **typecheck** — `uv run mypy`. + - **test** — matrix `python-version: ["3.12", "3.13"]`, `uv run pytest + --cov=wardline --cov-report=term-missing --cov-fail-under=90`. (Current + coverage is 94% / 4165 stmts; 90% is an honest floor with headroom.) +- Keep, rewired onto uv, and gated on the new jobs as appropriate: + - **self-hosting-scan** (dogfood `wardline scan src/wardline --format sarif` → + upload-sarif), `needs` the test job. + - **network** (weekly `pytest -m network`, schedule-only). + - **docs** (mkdocs `build --strict`; `gh-deploy` on push to main). +- `ruff format` is introduced as a check; the repo must be formatted once so the + check passes from day one (see Risks). + +### 4. Makefile + +filigree-style with a `help` default target. Targets (all via `uv run` / +`uv sync`): `install`, `lint`, `format`, `typecheck`, `test`, `test-cov` +(`--cov-fail-under=90`), `scan-self` (dogfood), `docs` (`mkdocs serve`), +`build` (`uv build`), `clean`, and `ci` (`lint typecheck test-cov`). + +### 5. .pre-commit-config.yaml + +`astral-sh/ruff-pre-commit` with `ruff-check --fix` and `ruff-format`. (No mypy +in pre-commit — keep commits fast; mypy is a CI/`make` gate.) + +### 6. Project docs + +- **CONTRIBUTING.md** expanded to filigree depth: uv dev setup, code-style + (ruff line-length 120, mypy strict), a Conventional Commits type table, PR + process gated on `make ci`, bug/feature report template links, a "first-time + contributors" pointer, and an Architecture section that points at `CLAUDE.md` + rather than duplicating it. TDD expectation retained from the current file. +- **ROADMAP.md** — short and honest: current state (0.3.0, SP0–SP9 shipped), + the deliberate **L1–L2 scope** (not full L3), and near-term threads linked to + the open Filigree issues (N-hop `explain_taint` chain completeness; + return-indirection in `compute_return_callee`; taint-combination hardening + epic; star-import decorator-marker FN). No invented dates. +- **CODE_OF_CONDUCT.md** — expand the 279-byte stub to the standard Contributor + Covenant v2.1, contact `john@wardline.dev`. +- **SECURITY.md** — already adequate; leave as-is. + +## Risks & mitigations + +- **`ruff format --check` may fail on the existing tree.** Mitigation: run + `uv run ruff format src tests` once as part of this work and commit the + (expected-minimal) reformat before the check goes live. Verify the diff is + formatting-only. +- **uv.lock drift / uv not in CI.** Mitigation: generate `uv.lock` locally with + the available `uv 0.10.2`; CI uses `astral-sh/setup-uv` pinned. `uv sync` + is `--frozen`-compatible. +- **Dependency-group migration breaks `pip install -e .[dev]` muscle memory.** + Mitigation: CONTRIBUTING and CLAUDE/AGENTS dev commands switch to + `uv sync --all-extras --group dev`; the README Development section says the + same. (`pip install -e .[scanner]` etc. for *runtime* extras still works.) +- **Coverage floor too tight.** Mitigation: floor set at 90% vs measured 94%. +- **Release pipeline regression.** Mitigation: only the build *step* changes to + `uv build`; the publish job (Trusted Publishing) is byte-for-byte preserved + and called out as a hard constraint. + +## Verification + +- `uv sync --all-extras --group dev` succeeds from a clean checkout. +- `make ci` is green: ruff check + ruff format --check + mypy strict + pytest + with `--cov-fail-under=90`. +- `uv build` produces an sdist + wheel; `twine check dist/*` passes (local + proxy for the release job). +- `mkdocs build --strict` is green. +- README renders (no broken links to the doc-site pages); badges resolve. +- `CLAUDE.md` and `AGENTS.md` carry identical Wardline developer guidance + the + Audience banner, with the Filigree block intact below the markers in both. +- `wardline scan src/wardline` still runs clean (dogfood unaffected). diff --git a/pyproject.toml b/pyproject.toml index e3a383d2..5d9a89ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,12 @@ scanner = ["pyyaml>=6.0", "jsonschema>=4.0", "click>=8.0"] docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"] clarion = ["blake3>=1.0"] # The SP5 LLM triage judge is dependency-free (stdlib urllib -> OpenRouter); no extra needed. + +[dependency-groups] +# Tooling only. Runtime deps live in the extras above; `uv sync --all-extras +# --group dev` installs base + scanner + clarion + docs + this group, which is +# the canonical dev sync used by the Makefile, CI, and CONTRIBUTING. dev = [ - "wardline[scanner]", - "wardline[clarion]", "pytest>=8.0", "pytest-cov>=5.0", "pytest-randomly", @@ -76,3 +79,10 @@ markers = [ "network: tests that need network (live OpenRouter judge e2e — SP5)", "clarion_e2e: tests that need a real `clarion serve` binary (SP9 round-trip)", ] + +[tool.coverage.run] +source = ["wardline"] +branch = true + +[tool.coverage.report] +show_missing = true diff --git a/src/wardline/clarion/__init__.py b/src/wardline/clarion/__init__.py index 14df2542..f3e5c8a4 100644 --- a/src/wardline/clarion/__init__.py +++ b/src/wardline/clarion/__init__.py @@ -23,7 +23,6 @@ def require_blake3() -> ModuleType: import blake3 except ModuleNotFoundError as exc: raise ClarionError( - "the Clarion integration needs blake3 — install it with: " - "pip install 'wardline[clarion]'" + "the Clarion integration needs blake3 — install it with: pip install 'wardline[clarion]'" ) from exc return blake3 diff --git a/src/wardline/clarion/client.py b/src/wardline/clarion/client.py index 3d8e5b8e..84d1cab1 100644 --- a/src/wardline/clarion/client.py +++ b/src/wardline/clarion/client.py @@ -34,23 +34,17 @@ class Response: class Transport(Protocol): - def request( - self, method: str, url: str, body: bytes, headers: Mapping[str, str] - ) -> Response: ... + def request(self, method: str, url: str, body: bytes, headers: Mapping[str, str]) -> Response: ... class UrllibTransport: def __init__(self, timeout: float = 30.0) -> None: self._timeout = timeout - def request( - self, method: str, url: str, body: bytes, headers: Mapping[str, str] - ) -> Response: + def request(self, method: str, url: str, body: bytes, headers: Mapping[str, str]) -> Response: scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in _ALLOWED_SCHEMES: - raise ClarionError( - f"--clarion-url must use http or https; got scheme {scheme!r} in {url!r}" - ) + raise ClarionError(f"--clarion-url must use http or https; got scheme {scheme!r} in {url!r}") data = body if body else None req = urllib.request.Request(url, data=data, headers=dict(headers), method=method) try: @@ -87,7 +81,7 @@ def from_wire(cls, obj: Mapping[str, Any]) -> TaintFactView: return cls( qualname=str(obj.get("qualname", "")), exists=bool(obj.get("exists", False)), - wardline_json=obj.get("wardline_json"), # field-absent → None + wardline_json=obj.get("wardline_json"), # field-absent → None current_content_hash=obj.get("current_content_hash"), # field-absent → None ) @@ -142,9 +136,7 @@ def _send(self, method: str, path_and_query: str, payload: dict[str, Any] | None def _require_ok(self, resp: Response, path: str) -> dict[str, Any]: """For routes with no soft 4xx: 2xx → parsed dict; anything else → loud.""" if not 200 <= resp.status < 300: - raise ClarionError( - f"Clarion rejected {path} ({resp.status}; code={_error_code(resp.body)}): {resp.body}" - ) + raise ClarionError(f"Clarion rejected {path} ({resp.status}; code={_error_code(resp.body)}): {resp.body}") try: parsed = json.loads(resp.body) if resp.body else {} except json.JSONDecodeError: @@ -218,8 +210,6 @@ def batch_get(self, qualnames: list[str]) -> list[TaintFactView] | None: except json.JSONDecodeError: parsed = [] if not 200 <= resp.status < 300 or not isinstance(parsed, list): - raise ClarionError( - f"Clarion rejected batch-get ({resp.status}; code={_error_code(resp.body)})" - ) + raise ClarionError(f"Clarion rejected batch-get ({resp.status}; code={_error_code(resp.body)})") views.extend(TaintFactView.from_wire(o) for o in parsed if isinstance(o, dict)) return views diff --git a/src/wardline/clarion/facts.py b/src/wardline/clarion/facts.py index a1e698e6..802b4c23 100644 --- a/src/wardline/clarion/facts.py +++ b/src/wardline/clarion/facts.py @@ -35,9 +35,7 @@ def _read_bytes(path: Path) -> bytes: return path.read_bytes() -def _resolve_callee_qualname( - context: AnalysisContext, qualname: str, callee: str | None -) -> str | None: +def _resolve_callee_qualname(context: AnalysisContext, qualname: str, callee: str | None) -> str | None: """Resolve the bare contributing-callee name to a same-module entity qualname for the chain walk, mirroring explain_finding's honest 1-hop rule: only when the callee is a simple (non-dotted) name AND `.` is a known entity. Otherwise @@ -63,8 +61,7 @@ def build_taint_facts(result: ScanResult, root: Path) -> list[dict[str, Any]]: if f.qualname is None: continue findings_by_qualname.setdefault(f.qualname, []).append( - {"rule_id": f.rule_id, "fingerprint": f.fingerprint, - "line_start": f.location.line_start} + {"rule_id": f.rule_id, "fingerprint": f.fingerprint, "line_start": f.location.line_start} ) facts: list[dict[str, Any]] = [] @@ -93,9 +90,11 @@ def build_taint_facts(result: ScanResult, root: Path) -> list[dict[str, Any]]: }, "findings": findings_by_qualname.get(qualname, []), } - facts.append({ - "qualname": qualname, - "wardline_json": blob, - "content_hash_at_compute": content_hash, - }) + facts.append( + { + "qualname": qualname, + "wardline_json": blob, + "content_hash_at_compute": content_hash, + } + ) return facts diff --git a/src/wardline/cli/install.py b/src/wardline/cli/install.py index 5a5a3502..ba179c48 100644 --- a/src/wardline/cli/install.py +++ b/src/wardline/cli/install.py @@ -15,8 +15,12 @@ @click.command() -@click.option("--root", type=click.Path(exists=True, file_okay=False, path_type=Path), - default=".", help="Project root to install into (default: cwd).") +@click.option( + "--root", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", + help="Project root to install into (default: cwd).", +) @click.option("--no-claude-md", is_flag=True, help="Skip the CLAUDE.md instruction block.") @click.option("--no-agents-md", is_flag=True, help="Skip the AGENTS.md instruction block.") @click.option("--no-skill", is_flag=True, help="Skip the wardline-gate skill.") diff --git a/src/wardline/cli/judge.py b/src/wardline/cli/judge.py index 258877fe..7d6a7387 100644 --- a/src/wardline/cli/judge.py +++ b/src/wardline/cli/judge.py @@ -34,8 +34,13 @@ @click.option("--model", default=None, help="OpenRouter model slug (overrides config).") @click.option("--context-lines", type=int, default=None, help="Excerpt radius (default 30).") @click.option("--max-findings", type=int, default=None, help="Cap findings triaged this run.") -@click.option("--write", "do_write", is_flag=True, default=False, - help="Append FALSE_POSITIVE verdicts to .wardline/judged.yaml (default: dry-run).") +@click.option( + "--write", + "do_write", + is_flag=True, + default=False, + help="Append FALSE_POSITIVE verdicts to .wardline/judged.yaml (default: dry-run).", +) def judge( path: Path, config_path: Path | None, diff --git a/src/wardline/cli/main.py b/src/wardline/cli/main.py index 0b32cf45..9abfe922 100644 --- a/src/wardline/cli/main.py +++ b/src/wardline/cli/main.py @@ -45,21 +45,15 @@ def vocab() -> None: def _generate_baseline(path: Path, *, overwrite: bool, config_path: Path | None) -> None: baseline_path = path / ".wardline" / "baseline.yaml" try: - to_baseline = collect_and_write_baseline( - path, overwrite=overwrite, config_path=config_path - ) + to_baseline = collect_and_write_baseline(path, overwrite=overwrite, config_path=config_path) except FileExistsError: - click.echo( - f"{baseline_path} already exists; use `wardline baseline update` to overwrite.", err=True - ) + click.echo(f"{baseline_path} already exists; use `wardline baseline update` to overwrite.", err=True) raise SystemExit(2) from None except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc counts = Counter(f.severity.value for f in to_baseline) - breakdown = ", ".join( - f"{n} {sev}" for sev, n in sorted(counts.items(), key=lambda kv: _SEV_PRINT_ORDER[kv[0]]) - ) + breakdown = ", ".join(f"{n} {sev}" for sev, n in sorted(counts.items(), key=lambda kv: _SEV_PRINT_ORDER[kv[0]])) click.echo(f"baselined {len(to_baseline)} finding(s) -> {baseline_path}" + (f": {breakdown}" if breakdown else "")) @@ -85,5 +79,3 @@ def baseline_create(path: Path, config_path: Path | None) -> None: def baseline_update(path: Path, config_path: Path | None) -> None: """Re-derive and overwrite the baseline from current findings.""" _generate_baseline(path, overwrite=True, config_path=config_path) - - diff --git a/src/wardline/cli/mcp.py b/src/wardline/cli/mcp.py index d1f016d4..155b8c64 100644 --- a/src/wardline/cli/mcp.py +++ b/src/wardline/cli/mcp.py @@ -11,10 +11,18 @@ @click.command() -@click.option("--root", type=click.Path(exists=True, file_okay=False, path_type=Path), - default=".", help="Project root the server scans (default: cwd).") -@click.option("--clarion-url", "clarion_url", default=None, - help="Clarion taint-store URL: `scan` writes facts; `explain_taint` queries it.") +@click.option( + "--root", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", + help="Project root the server scans (default: cwd).", +) +@click.option( + "--clarion-url", + "clarion_url", + default=None, + help="Clarion taint-store URL: `scan` writes facts; `explain_taint` queries it.", +) def mcp(root: Path, clarion_url: str | None) -> None: """Run the Wardline MCP server over stdio (JSON-RPC 2.0).""" from wardline.core.config import resolve_clarion_url diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index 56da2521..0061fbd4 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -31,14 +31,29 @@ # (parse error / too-deep / missing source root — NOT benign no-module skips). # Default FALSE preserves the released exit-code behaviour; the count is ALWAYS # surfaced. -@click.option("--fail-on-unanalyzed/--no-fail-on-unanalyzed", default=False, - help="Exit 1 if any file was discovered but could not be analyzed.") -@click.option("--cache-dir", type=click.Path(path_type=Path), default=None, - help="Persist L3 summary cache here for faster incremental scans.") -@click.option("--filigree-url", "filigree_url", default=None, - help="POST findings to this Filigree Loom scan-results URL (opt-in).") -@click.option("--clarion-url", "clarion_url", default=None, - help="Persist per-entity taint facts to this Clarion taint-store URL (opt-in, fail-soft).") +@click.option( + "--fail-on-unanalyzed/--no-fail-on-unanalyzed", + default=False, + help="Exit 1 if any file was discovered but could not be analyzed.", +) +@click.option( + "--cache-dir", + type=click.Path(path_type=Path), + default=None, + help="Persist L3 summary cache here for faster incremental scans.", +) +@click.option( + "--filigree-url", + "filigree_url", + default=None, + help="POST findings to this Filigree Loom scan-results URL (opt-in).", +) +@click.option( + "--clarion-url", + "clarion_url", + default=None, + help="Persist per-entity taint facts to this Clarion taint-store URL (opt-in, fail-soft).", +) def scan( path: Path, config_path: Path | None, @@ -86,8 +101,7 @@ def scan( if emit_result is not None: if not emit_result.reachable: click.echo( - f"warning: could not reach Filigree at {filigree_url}; " - f"findings written locally only.", + f"warning: could not reach Filigree at {filigree_url}; findings written locally only.", err=True, ) else: @@ -113,9 +127,7 @@ def scan( line += f"; {len(clarion_result.unresolved_qualnames)} qualname(s) unresolved (not indexed by Clarion)" click.echo(line) s = result.summary - unanalyzed_segment = ( - f"; {s.unanalyzed} file(s) could not be analyzed" if s.unanalyzed else "" - ) + unanalyzed_segment = f"; {s.unanalyzed} file(s) could not be analyzed" if s.unanalyzed else "" click.echo( f"scanned {result.files_scanned} file(s); {s.total} finding(s) — " f"{s.baselined + s.waived + s.judged} suppressed " diff --git a/src/wardline/core/baseline.py b/src/wardline/core/baseline.py index fb2dc98b..e6040774 100644 --- a/src/wardline/core/baseline.py +++ b/src/wardline/core/baseline.py @@ -28,7 +28,11 @@ # CRITICAL sorts first so high-severity entries sit at the top of the git diff. _SEVERITY_SORT: dict[Severity, int] = { - Severity.CRITICAL: 0, Severity.ERROR: 1, Severity.WARN: 2, Severity.INFO: 3, Severity.NONE: 4, + Severity.CRITICAL: 0, + Severity.ERROR: 1, + Severity.WARN: 2, + Severity.INFO: 3, + Severity.NONE: 4, } _HEX = frozenset("0123456789abcdef") @@ -98,11 +102,7 @@ def collect_and_write_baseline( today = date.today() files = discover(root, cfg, confine_to_root=confine_to_root) findings = WardlineAnalyzer().analyze(files, cfg, root=root) - to_baseline = [ - f - for f in findings - if f.kind is Kind.DEFECT and waivers.match(f.fingerprint, today) is None - ] + to_baseline = [f for f in findings if f.kind is Kind.DEFECT and waivers.match(f.fingerprint, today) is None] write_baseline(baseline_path, to_baseline) return to_baseline diff --git a/src/wardline/core/config.py b/src/wardline/core/config.py index d816615a..13020d70 100644 --- a/src/wardline/core/config.py +++ b/src/wardline/core/config.py @@ -74,9 +74,7 @@ def _config_for(root: Path, config_path: Path | None) -> WardlineConfig: return load(config_path if config_path is not None else root / "wardline.yaml") -def resolve_clarion_url( - flag: str | None, root: Path, config_path: Path | None = None -) -> str | None: +def resolve_clarion_url(flag: str | None, root: Path, config_path: Path | None = None) -> str | None: """Clarion URL by precedence: explicit flag > env var > wardline.yaml.""" if flag is not None: return flag @@ -86,9 +84,7 @@ def resolve_clarion_url( return _config_for(root, config_path).clarion_url -def resolve_filigree_url( - flag: str | None, root: Path, config_path: Path | None = None -) -> str | None: +def resolve_filigree_url(flag: str | None, root: Path, config_path: Path | None = None) -> str | None: """Filigree Loom URL by precedence: explicit flag > env var > wardline.yaml.""" if flag is not None: return flag diff --git a/src/wardline/core/discovery.py b/src/wardline/core/discovery.py index bd7449e4..5527acdd 100644 --- a/src/wardline/core/discovery.py +++ b/src/wardline/core/discovery.py @@ -14,9 +14,7 @@ _ALWAYS_SKIP = frozenset({"__pycache__", ".venv", "venv", ".git", ".mypy_cache"}) -def discover( - root: Path, config: WardlineConfig, *, confine_to_root: bool = False -) -> list[Path]: +def discover(root: Path, config: WardlineConfig, *, confine_to_root: bool = False) -> list[Path]: root = root.resolve() found: list[Path] = [] for src in config.source_roots: @@ -26,8 +24,7 @@ def discover( # would otherwise read out-of-root source. Reject (do NOT silently # skip — a silent skip under-scans and gives a false all-clear). raise ConfigError( - f"source_root {src!r} resolves outside the project root; " - "refusing to scan outside the root" + f"source_root {src!r} resolves outside the project root; refusing to scan outside the root" ) if not base.exists(): warnings.warn(f"source root does not exist: {base}", stacklevel=2) @@ -41,20 +38,14 @@ def discover( # so only file symlinks leak). Refuse to read out-of-root content # by skipping it — the MCP confinement guarantee (THREAT-001). continue - relposix = ( - path.relative_to(root).as_posix() - if path.is_relative_to(root) - else path.as_posix() - ) + relposix = path.relative_to(root).as_posix() if path.is_relative_to(root) else path.as_posix() if _excluded(relposix, config.exclude): continue found.append(path) return found -def missing_source_roots( - root: Path, config: WardlineConfig, *, confine_to_root: bool = False -) -> list[str]: +def missing_source_roots(root: Path, config: WardlineConfig, *, confine_to_root: bool = False) -> list[str]: """Return the configured ``source_roots`` that do not exist on disk. ``discover`` skips a non-existent root with a ``warnings.warn`` (invisible to a diff --git a/src/wardline/core/explain.py b/src/wardline/core/explain.py index 966dd651..e4853b1c 100644 --- a/src/wardline/core/explain.py +++ b/src/wardline/core/explain.py @@ -30,8 +30,8 @@ class TaintExplanation: sink_qualname: str | None path: str line: int | None - tier_in: str | None # actual (untrusted) tier arriving at the sink - tier_out: str | None # tier the sink declares it returns + tier_in: str | None # actual (untrusted) tier arriving at the sink + tier_out: str | None # tier the sink declares it returns immediate_tainted_callee: str | None source_boundary_qualname: str | None resolved_call_count: int @@ -81,9 +81,7 @@ def _explain_local( context = result.context qualname = finding.qualname - immediate_tainted_callee = ( - context.function_return_callee.get(qualname) if qualname is not None else None - ) + immediate_tainted_callee = context.function_return_callee.get(qualname) if qualname is not None else None # Resolve the source boundary ONE hop, honestly. If the immediate callee is a # simple (non-dotted) name, form the same-module candidate qualname and report @@ -99,10 +97,7 @@ def _explain_local( ): module = qualname.rsplit(".", 1)[0] candidate = f"{module}.{immediate_tainted_callee}" - if ( - candidate in context.entities - and context.function_return_callee.get(candidate) is None - ): + if candidate in context.entities and context.function_return_callee.get(candidate) is None: source_boundary_qualname = candidate prov = context.taint_provenance.get(qualname) if qualname is not None else None @@ -220,12 +215,14 @@ def explain_chain( blob = view.wardline_json or {} taint = blob.get("taint", {}) next_q = taint.get("contributing_callee_qualname") - hops.append(ChainHop( - qualname=current, - tier_in=taint.get("actual_return"), - tier_out=taint.get("declared_return"), - contributing_callee_qualname=next_q, - )) + hops.append( + ChainHop( + qualname=current, + tier_in=taint.get("actual_return"), + tier_out=taint.get("declared_return"), + contributing_callee_qualname=next_q, + ) + ) current = next_q # None at the boundary leaf → clean finish return TaintChain(hops=hops, truncated_at=None) @@ -265,6 +262,10 @@ def explain_finding( ) # miss/stale/outage → fall through to the re-run return _explain_local( - root, fingerprint=fingerprint, path=path, line=line, - config_path=config_path, confine_to_root=confine_to_root, + root, + fingerprint=fingerprint, + path=path, + line=line, + config_path=config_path, + confine_to_root=confine_to_root, ) diff --git a/src/wardline/core/filigree_emit.py b/src/wardline/core/filigree_emit.py index 92489098..9a67e31e 100644 --- a/src/wardline/core/filigree_emit.py +++ b/src/wardline/core/filigree_emit.py @@ -55,9 +55,7 @@ def _finding_to_wire(finding: Finding) -> dict[str, Any]: return wire -def build_scan_results_body( - findings: Sequence[Finding], *, scan_source: str = "wardline" -) -> dict[str, Any]: +def build_scan_results_body(findings: Sequence[Finding], *, scan_source: str = "wardline") -> dict[str, Any]: """Build the ``POST /api/loom/scan-results`` request body. Emits ALL finding kinds.""" return { "scan_source": scan_source, @@ -96,9 +94,7 @@ def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: # an ingest target — turn it into a clean loud failure (and justify the S310 below). scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in _ALLOWED_SCHEMES: - raise FiligreeEmitError( - f"--filigree-url must use http or https; got scheme {scheme!r} in {url!r}" - ) + raise FiligreeEmitError(f"--filigree-url must use http or https; got scheme {scheme!r} in {url!r}") request = urllib.request.Request(url, data=body, headers=dict(headers), method="POST") try: with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310 @@ -134,9 +130,7 @@ def emit(self, findings: Sequence[Finding]) -> EmitResult: if not 200 <= resp.status < 300: # 3xx (a redirect reached the client) or 4xx (request rejected): Wardline sent a # request the server would not accept — bad payload / wrong endpoint / auth. Loud. - raise FiligreeEmitError( - f"Filigree rejected scan-results ({resp.status}) at {self._url}: {resp.body}" - ) + raise FiligreeEmitError(f"Filigree rejected scan-results ({resp.status}) at {self._url}: {resp.body}") # 2xx success. Parse defensively: a 2xx with an unreadable body means the POST was # accepted but the report is unparseable — surface a warning, never crash (charter). warnings: list[str] = [] @@ -146,9 +140,7 @@ def emit(self, findings: Sequence[Finding]) -> EmitResult: parsed = None payload: dict[str, Any] = parsed if isinstance(parsed, dict) else {} if not isinstance(parsed, dict): - warnings.append( - f"Filigree returned {resp.status} with a non-JSON-object body; stats unavailable." - ) + warnings.append(f"Filigree returned {resp.status} with a non-JSON-object body; stats unavailable.") raw_stats = payload.get("stats") stats: dict[str, Any] = raw_stats if isinstance(raw_stats, dict) else {} raw_warnings = payload.get("warnings") diff --git a/src/wardline/core/finding.py b/src/wardline/core/finding.py index 2067539a..c82f78a4 100644 --- a/src/wardline/core/finding.py +++ b/src/wardline/core/finding.py @@ -65,10 +65,10 @@ class Kind(StrEnum): class SuppressionState(StrEnum): - ACTIVE = "active" # not suppressed — the default + ACTIVE = "active" # not suppressed — the default BASELINED = "baselined" # matched a baseline fingerprint - WAIVED = "waived" # matched an active waiver - JUDGED = "judged" # LLM triage judged it a FALSE_POSITIVE (SP5) + WAIVED = "waived" # matched an active waiver + JUDGED = "judged" # LLM triage judged it a FALSE_POSITIVE (SP5) @dataclass(frozen=True, slots=True) diff --git a/src/wardline/core/judge.py b/src/wardline/core/judge.py index acbe512e..5cfee036 100644 --- a/src/wardline/core/judge.py +++ b/src/wardline/core/judge.py @@ -38,7 +38,7 @@ class JudgeVerdict(StrEnum): - TRUE_POSITIVE = "TRUE_POSITIVE" # a real defect; leave it active + TRUE_POSITIVE = "TRUE_POSITIVE" # a real defect; leave it active FALSE_POSITIVE = "FALSE_POSITIVE" # analyzer over-approximation; suppressible @@ -265,9 +265,7 @@ def __init__(self, timeout: float = 60.0) -> None: def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in _ALLOWED_SCHEMES: - raise JudgeConfigurationError( - f"judge URL must use http or https; got scheme {scheme!r} in {url!r}" - ) + raise JudgeConfigurationError(f"judge URL must use http or https; got scheme {scheme!r} in {url!r}") request = urllib.request.Request(url, data=body, headers=dict(headers), method="POST") try: with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310 @@ -307,12 +305,14 @@ def call_judge( raise ValueError(f"max_tokens must be positive, got {max_tokens}") transport = transport if transport is not None else UrllibTransport() - body = json.dumps({ - "model": model_id, - "max_tokens": max_tokens, - "temperature": 0, - "messages": build_messages(request, policy_block=policy_block), - }).encode("utf-8") + body = json.dumps( + { + "model": model_id, + "max_tokens": max_tokens, + "temperature": 0, + "messages": build_messages(request, policy_block=policy_block), + } + ).encode("utf-8") headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} try: resp = transport.post(_OPENROUTER_URL, body, headers) diff --git a/src/wardline/core/judge_run.py b/src/wardline/core/judge_run.py index bece7939..7adf7d52 100644 --- a/src/wardline/core/judge_run.py +++ b/src/wardline/core/judge_run.py @@ -105,11 +105,19 @@ def _persist(root: Path, existing: JudgedSet, result: TriageResult, *, floor: fl new: list[JudgedFP] = [e for fp in existing.fingerprints() if (e := existing.match(fp)) is not None] for tv in writable: f, r = tv.finding, tv.response - new.append(JudgedFP( - fingerprint=f.fingerprint, rule_id=f.rule_id, path=f.location.path, message=f.message, - rationale=r.rationale, model_id=r.model_id, confidence=r.confidence, - recorded_at=r.recorded_at, policy_hash=r.policy_hash, - )) + new.append( + JudgedFP( + fingerprint=f.fingerprint, + rule_id=f.rule_id, + path=f.location.path, + message=f.message, + rationale=r.rationale, + model_id=r.model_id, + confidence=r.confidence, + recorded_at=r.recorded_at, + policy_hash=r.policy_hash, + ) + ) write_judged(judged_path, new) return len(writable), held_back diff --git a/src/wardline/core/judged.py b/src/wardline/core/judged.py index 55c5070a..ef08df20 100644 --- a/src/wardline/core/judged.py +++ b/src/wardline/core/judged.py @@ -75,9 +75,7 @@ def build_judged_document(entries: Iterable[JudgedFP]) -> dict[str, Any]: def write_judged(path: Path, entries: Iterable[JudgedFP]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - text = yaml.safe_dump( - build_judged_document(entries), sort_keys=False, default_flow_style=False, allow_unicode=True - ) + text = yaml.safe_dump(build_judged_document(entries), sort_keys=False, default_flow_style=False, allow_unicode=True) path.write_text(text, encoding="utf-8") @@ -115,12 +113,19 @@ def load_judged(path: Path) -> JudgedSet: policy_hash = _require_str(e, "policy_hash", idx, path.name) confidence = _require_confidence(e, idx, path.name) recorded_at = _parse_dt(e.get("recorded_at"), idx, path.name) - entries.append(JudgedFP( - fingerprint=fp, rule_id=str(e.get("rule_id", "")), path=str(e.get("path", "")), - message=str(e.get("message", "")), rationale=rationale, - model_id=model_id, confidence=confidence, - recorded_at=recorded_at, policy_hash=policy_hash, - )) + entries.append( + JudgedFP( + fingerprint=fp, + rule_id=str(e.get("rule_id", "")), + path=str(e.get("path", "")), + message=str(e.get("message", "")), + rationale=rationale, + model_id=model_id, + confidence=confidence, + recorded_at=recorded_at, + policy_hash=policy_hash, + ) + ) return JudgedSet(entries) diff --git a/src/wardline/core/protocols.py b/src/wardline/core/protocols.py index a5fae290..ca631e76 100644 --- a/src/wardline/core/protocols.py +++ b/src/wardline/core/protocols.py @@ -12,9 +12,7 @@ class Analyzer(Protocol): - def analyze( - self, files: Sequence[Path], config: WardlineConfig, *, root: Path - ) -> Sequence[Finding]: ... + def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) -> Sequence[Finding]: ... class Rule(Protocol): diff --git a/src/wardline/core/registry.py b/src/wardline/core/registry.py index a53852c9..e4abe803 100644 --- a/src/wardline/core/registry.py +++ b/src/wardline/core/registry.py @@ -39,9 +39,7 @@ def __post_init__(self) -> None: _ENTRIES: dict[str, RegistryEntry] = { - "external_boundary": RegistryEntry( - canonical_name="external_boundary", group=1, attrs={} - ), + "external_boundary": RegistryEntry(canonical_name="external_boundary", group=1, attrs={}), "trust_boundary": RegistryEntry( canonical_name="trust_boundary", group=1, @@ -57,9 +55,7 @@ def __post_init__(self) -> None: # Consistency invariant: every key equals its entry's canonical_name. for _name, _entry in _ENTRIES.items(): if _name != _entry.canonical_name: - raise ValueError( - f"REGISTRY key {_name!r} != canonical_name {_entry.canonical_name!r}" - ) + raise ValueError(f"REGISTRY key {_name!r} != canonical_name {_entry.canonical_name!r}") del _name, _entry REGISTRY: MappingProxyType[str, RegistryEntry] = MappingProxyType(_ENTRIES) diff --git a/src/wardline/core/run.py b/src/wardline/core/run.py index 8c0aa73c..8daab282 100644 --- a/src/wardline/core/run.py +++ b/src/wardline/core/run.py @@ -41,8 +41,8 @@ def _fp(*parts: str) -> str: @dataclass(frozen=True, slots=True) class ScanSummary: - total: int # every finding (defects + facts/metrics) - active: int # non-suppressed DEFECTs — the gate population + total: int # every finding (defects + facts/metrics) + active: int # non-suppressed DEFECTs — the gate population baselined: int waived: int judged: int @@ -68,7 +68,7 @@ class ScanResult: class GateDecision: tripped: bool fail_on: str | None - exit_class: int # 0 clean, 1 gate tripped, 2 reserved for tool errors (CLI layer) + exit_class: int # 0 clean, 1 gate tripped, 2 reserved for tool errors (CLI layer) def run_scan( diff --git a/src/wardline/core/sarif.py b/src/wardline/core/sarif.py index 0d40da75..72f418fb 100644 --- a/src/wardline/core/sarif.py +++ b/src/wardline/core/sarif.py @@ -14,7 +14,7 @@ from typing import Any from wardline import __version__ -from wardline.core.finding import Finding, Severity, SuppressionState +from wardline.core.finding import Finding, Kind, Severity, SuppressionState _SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" _INFO_URI = "https://github.com/foundryside/wardline" @@ -79,13 +79,21 @@ def _result(finding: Finding, rule_index: int) -> dict[str, Any]: def build_sarif(findings: Sequence[Finding]) -> dict[str, Any]: - """Build a SARIF 2.1.0 log with a single run from *findings* (pure).""" + """Build a SARIF 2.1.0 log with a single run from *findings* (pure). + + ``Kind.METRIC`` findings (engine telemetry such as WLN-L3-LOW-RESOLUTION + and WLN-ENGINE-METRICS) are excluded from the SARIF output. They carry + diagnostic statistics about the scan run itself — not actionable code + issues — and pollute GitHub Code Scanning with noise alerts. The full + picture (including METRIC findings) is always available in the JSONL sink. + """ + included = [f for f in findings if f.kind is not Kind.METRIC] rule_index: dict[str, int] = {} - for finding in findings: + for finding in included: if finding.rule_id not in rule_index: rule_index[finding.rule_id] = len(rule_index) rules = [{"id": rid} for rid in rule_index] - results = [_result(f, rule_index[f.rule_id]) for f in findings] + results = [_result(f, rule_index[f.rule_id]) for f in included] return { "version": "2.1.0", "$schema": _SCHEMA, @@ -111,6 +119,4 @@ def __init__(self, path: Path) -> None: def write(self, findings: Sequence[Finding]) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text( - json.dumps(build_sarif(findings), indent=2, ensure_ascii=False), encoding="utf-8" - ) + self._path.write_text(json.dumps(build_sarif(findings), indent=2, ensure_ascii=False), encoding="utf-8") diff --git a/src/wardline/core/suppression.py b/src/wardline/core/suppression.py index f508e259..c5b4535f 100644 --- a/src/wardline/core/suppression.py +++ b/src/wardline/core/suppression.py @@ -47,8 +47,7 @@ def apply_suppressions( # not apply — and they MUST surface (the "fail loud-but-survivable" safety net), # not abort the run. assert f.location.path == ENGINE_PATH or f.location.line_start is not None, ( - f"DEFECT {f.rule_id} entered suppression with line_start=None — " - f"weak fingerprint identity (collision risk)" + f"DEFECT {f.rule_id} entered suppression with line_start=None — weak fingerprint identity (collision risk)" ) # Precedence: waiver (explicit human intent, carries expiry) > judged (LLM # FP-verdict, carries the rationale) > baseline (silent). diff --git a/src/wardline/core/triage.py b/src/wardline/core/triage.py index 257c6629..ec714950 100644 --- a/src/wardline/core/triage.py +++ b/src/wardline/core/triage.py @@ -95,6 +95,8 @@ def run_triage( continue verdicts.append(TriageVerdict(finding=finding, response=response)) return TriageResult( - verdicts=verdicts, n_skipped_cap=n_cap, - n_skipped_transport=n_transport, n_skipped_excerpt=n_excerpt, + verdicts=verdicts, + n_skipped_cap=n_cap, + n_skipped_transport=n_transport, + n_skipped_excerpt=n_excerpt, ) diff --git a/src/wardline/core/waivers.py b/src/wardline/core/waivers.py index 677bded2..f9a400e4 100644 --- a/src/wardline/core/waivers.py +++ b/src/wardline/core/waivers.py @@ -69,9 +69,7 @@ def parse_waivers(raw: Sequence[Mapping[str, Any]]) -> tuple[Waiver, ...]: return tuple(waivers) -def add_waiver( - config_path: Path, *, fingerprint: str, reason: str, expires: date | None -) -> Waiver: +def add_waiver(config_path: Path, *, fingerprint: str, reason: str, expires: date | None) -> Waiver: """Append a waiver to ``config_path``'s ``waivers:`` list (creating the file if absent). Validates via the SAME rules as :func:`parse_waivers`, so a bad fingerprint or empty reason raises :class:`ConfigError` BEFORE any write. diff --git a/src/wardline/decorators/_base.py b/src/wardline/decorators/_base.py index 77458e76..1d2a6efa 100644 --- a/src/wardline/decorators/_base.py +++ b/src/wardline/decorators/_base.py @@ -17,9 +17,7 @@ from wardline.core.taints import TaintState -def coerce_level( - value: TaintState | str, *, allowed: frozenset[TaintState], arg: str -) -> TaintState: +def coerce_level(value: TaintState | str, *, allowed: frozenset[TaintState], arg: str) -> TaintState: """Normalise a level argument to a ``TaintState`` and check it is allowed. Accepts a ``TaintState`` or its exact name (e.g. ``"ASSURED"``). Raises @@ -38,9 +36,7 @@ def coerce_level( return level -def apply_marker( - fn: Any, *, name: str, group: int, attrs: dict[str, Any] -) -> Any: +def apply_marker(fn: Any, *, name: str, group: int, attrs: dict[str, Any]) -> Any: """Validate against ``REGISTRY`` and stamp marker attributes onto ``fn``. Returns ``fn`` unchanged (identity preserved). For ``staticmethod`` / @@ -52,22 +48,13 @@ def apply_marker( raise ValueError(f"Unknown decorator {name!r} — not in wardline registry") entry = REGISTRY[name] if group != entry.group: - raise ValueError( - f"Group mismatch for {name!r}: passed {group}, " - f"registry expects {entry.group}" - ) + raise ValueError(f"Group mismatch for {name!r}: passed {group}, registry expects {entry.group}") for attr_key in attrs: if attr_key not in entry.attrs: - raise ValueError( - f"Unknown attribute {attr_key!r} for {name!r}; " - f"allowed: {sorted(entry.attrs)}" - ) + raise ValueError(f"Unknown attribute {attr_key!r} for {name!r}; allowed: {sorted(entry.attrs)}") if not callable(fn) and not isinstance(fn, (staticmethod, classmethod)): - raise TypeError( - f"wardline decorator {name!r} requires a callable, " - f"got {type(fn).__name__!r}" - ) + raise TypeError(f"wardline decorator {name!r} requires a callable, got {type(fn).__name__!r}") target: Any = fn.__func__ if isinstance(fn, (staticmethod, classmethod)) else fn existing: frozenset[int] = getattr(target, "_wardline_groups", frozenset()) target._wardline_groups = existing | {group} diff --git a/src/wardline/decorators/trust.py b/src/wardline/decorators/trust.py index 5e3e17ce..13b5269e 100644 --- a/src/wardline/decorators/trust.py +++ b/src/wardline/decorators/trust.py @@ -40,7 +40,9 @@ def trust_boundary(*, to_level: TaintState | str) -> Callable[[Any], Any]: def decorate(fn: Any) -> Any: return apply_marker( - fn, name="trust_boundary", group=_GROUP, + fn, + name="trust_boundary", + group=_GROUP, attrs={"_wardline_to_level": level}, ) @@ -63,7 +65,9 @@ def decorate(target: F) -> F: return cast( F, apply_marker( - target, name="trusted", group=_GROUP, + target, + name="trusted", + group=_GROUP, attrs={"_wardline_level": coerced}, ), ) diff --git a/src/wardline/install/block.py b/src/wardline/install/block.py index 56e531db..93c93a37 100644 --- a/src/wardline/install/block.py +++ b/src/wardline/install/block.py @@ -28,11 +28,7 @@ def _body_hash() -> str: def render_block() -> str: - return ( - f"\n" - f"{_BODY}\n" - "" - ) + return f"\n{_BODY}\n" def inject_block(file_path: Path) -> str: diff --git a/src/wardline/mcp/server.py b/src/wardline/mcp/server.py index fc387e41..5cb3b6b6 100644 --- a/src/wardline/mcp/server.py +++ b/src/wardline/mcp/server.py @@ -85,6 +85,7 @@ def _scan(args: dict[str, Any], root: Path, clarion: Any = None) -> dict[str, An from wardline.clarion.client import WriteResult from wardline.clarion.write import write_facts_to_clarion from wardline.core.errors import ClarionError + try: wr = write_facts_to_clarion(result, path, clarion) except ClarionError as exc: @@ -113,15 +114,12 @@ def _scan(args: dict[str, Any], root: Path, clarion: Any = None) -> dict[str, An # silent under-scan reaches the agent, not just the human-facing stderr. "unanalyzed": result.summary.unanalyzed, }, - "gate": {"tripped": decision.tripped, "fail_on": decision.fail_on, - "exit_class": decision.exit_class}, + "gate": {"tripped": decision.tripped, "fail_on": decision.fail_on, "exit_class": decision.exit_class}, "clarion": clarion_block, } -def _explain_taint( - args: dict[str, Any], root: Path, clarion: Any = None -) -> dict[str, Any]: +def _explain_taint(args: dict[str, Any], root: Path, clarion: Any = None) -> dict[str, Any]: # The store-backed read path: when a Clarion store is configured and the caller # passes the finding's qualname as `sink_qualname`, explain_finding serves a FRESH # fact straight from the store with no re-scan; otherwise it falls back to the SP8 @@ -146,8 +144,7 @@ def _explain_taint( ) if exp is None: raise ToolError( - "fingerprint not in current scan; your code changed since the scan that " - "produced it — re-scan.", + "fingerprint not in current scan; your code changed since the scan that produced it — re-scan.", ) result_dict: dict[str, Any] = { "fingerprint": exp.fingerprint, @@ -162,12 +159,19 @@ def _explain_taint( "unresolved_call_count": exp.unresolved_call_count, } if args.get("chain") and clarion is not None and exp.sink_qualname: - ch = explain_chain(root, sink_qualname=exp.sink_qualname, clarion=clarion, - max_hops=int(args.get("max_hops", 20))) + ch = explain_chain( + root, sink_qualname=exp.sink_qualname, clarion=clarion, max_hops=int(args.get("max_hops", 20)) + ) result_dict["chain"] = { - "hops": [{"qualname": h.qualname, "tier_in": h.tier_in, "tier_out": h.tier_out, - "contributing_callee_qualname": h.contributing_callee_qualname} - for h in ch.hops], + "hops": [ + { + "qualname": h.qualname, + "tier_in": h.tier_in, + "tier_out": h.tier_out, + "contributing_callee_qualname": h.contributing_callee_qualname, + } + for h in ch.hops + ], "truncated_at": ch.truncated_at, } return result_dict @@ -197,9 +201,15 @@ def _judge(args: dict[str, Any], root: Path) -> dict[str, Any]: ) return { "verdicts": [ - {"fingerprint": v.fingerprint, "rule_id": v.rule_id, "path": v.path, - "line": v.line, "label": v.label, "confidence": v.confidence, - "rationale": v.rationale} + { + "fingerprint": v.fingerprint, + "rule_id": v.rule_id, + "path": v.path, + "line": v.line, + "label": v.label, + "confidence": v.confidence, + "rationale": v.rationale, + } for v in outcome.verdicts ], "wrote": outcome.wrote, @@ -210,25 +220,17 @@ def _judge(args: dict[str, Any], root: Path) -> dict[str, Any]: def _baseline_create(args: dict[str, Any], root: Path) -> dict[str, Any]: reason = _require(args, "reason") try: - count = generate_baseline( - root, overwrite=False, config_path=_cfg(args, root), confine_to_root=True - ) + count = generate_baseline(root, overwrite=False, config_path=_cfg(args, root), confine_to_root=True) except FileExistsError as exc: # No-clobber refuse path: agent-actionable — point at baseline_update. - raise ToolError( - "a baseline already exists; call baseline_update to overwrite it" - ) from exc - return {"baselined_count": count, "path": str(root / ".wardline" / "baseline.yaml"), - "reason": reason} + raise ToolError("a baseline already exists; call baseline_update to overwrite it") from exc + return {"baselined_count": count, "path": str(root / ".wardline" / "baseline.yaml"), "reason": reason} def _baseline_update(args: dict[str, Any], root: Path) -> dict[str, Any]: reason = _require(args, "reason") - count = generate_baseline( - root, overwrite=True, config_path=_cfg(args, root), confine_to_root=True - ) - return {"baselined_count": count, "path": str(root / ".wardline" / "baseline.yaml"), - "reason": reason} + count = generate_baseline(root, overwrite=True, config_path=_cfg(args, root), confine_to_root=True) + return {"baselined_count": count, "path": str(root / ".wardline" / "baseline.yaml"), "reason": reason} def _waiver_add(args: dict[str, Any], root: Path) -> dict[str, Any]: @@ -241,8 +243,11 @@ def _waiver_add(args: dict[str, Any], root: Path) -> dict[str, Any]: # A malformed date is something the agent can fix and should see. raise ToolError("expires must be an ISO date (YYYY-MM-DD)") from exc waiver = add_waiver(root / "wardline.yaml", fingerprint=fp, reason=reason, expires=expires) - return {"fingerprint": waiver.fingerprint, "reason": waiver.reason, - "expires": waiver.expires.isoformat() if waiver.expires else None} + return { + "fingerprint": waiver.fingerprint, + "reason": waiver.reason, + "expires": waiver.expires.isoformat() if waiver.expires else None, + } # Gate thresholds are the four defect severities. Severity also defines NONE @@ -266,6 +271,7 @@ def _clarion_client(self) -> Any: return None from wardline.clarion.client import ClarionClient from wardline.clarion.config import load_clarion_token, resolve_project_name + return ClarionClient( self.clarion_url, secret=load_clarion_token(self.root), @@ -273,81 +279,110 @@ def _clarion_client(self) -> Any: ) def _register_tools(self) -> None: - self.add_tool(Tool( - name="scan", - description="Whole-program taint scan of the project. Returns structured " - "findings, the suppression summary (active = the gate population), " - "and the gate verdict.", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string", "description": "subdir relative to project root"}, - "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, - "config": {"type": "string"}, + self.add_tool( + Tool( + name="scan", + description="Whole-program taint scan of the project. Returns structured " + "findings, the suppression summary (active = the gate population), " + "and the gate verdict.", + input_schema={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "subdir relative to project root"}, + "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, + "config": {"type": "string"}, + }, }, - }, - handler=lambda args, root: _scan(args, root, self._clarion_client()), - )) - self.add_tool(Tool( - name="explain_taint", - description="Explain ONE finding's taint: the immediate tainted callee, the " - "originating boundary, and the trust tiers at the sink. Call right " - "after scan and before editing — a stale fingerprint returns an error. " - "Pass the finding's `qualname` as `sink_qualname`: when a Clarion store " - "is configured this serves the explanation from the store instead of " - "re-scanning. Pass `chain: true` (needs a configured Clarion store) to " - "also walk the full taint chain from the sink to the originating boundary; " - "without a store it degrades to the single-hop explanation (no `chain` block).", - input_schema={ - "type": "object", - "properties": { - "fingerprint": {"type": "string"}, - "path": {"type": "string"}, - "line": {"type": "integer"}, - "sink_qualname": {"type": "string"}, - "chain": {"type": "boolean"}, - "max_hops": {"type": "integer"}, - "config": {"type": "string"}, + handler=lambda args, root: _scan(args, root, self._clarion_client()), + ) + ) + self.add_tool( + Tool( + name="explain_taint", + description="Explain ONE finding's taint: the immediate tainted callee, the " + "originating boundary, and the trust tiers at the sink. Call right " + "after scan and before editing — a stale fingerprint returns an error. " + "Pass the finding's `qualname` as `sink_qualname`: when a Clarion store " + "is configured this serves the explanation from the store instead of " + "re-scanning. Pass `chain: true` (needs a configured Clarion store) to " + "also walk the full taint chain from the sink to the originating boundary; " + "without a store it degrades to the single-hop explanation (no `chain` block).", + input_schema={ + "type": "object", + "properties": { + "fingerprint": {"type": "string"}, + "path": {"type": "string"}, + "line": {"type": "integer"}, + "sink_qualname": {"type": "string"}, + "chain": {"type": "boolean"}, + "max_hops": {"type": "integer"}, + "config": {"type": "string"}, + }, + }, + handler=lambda args, root: _explain_taint(args, root, self._clarion_client()), + ) + ) + self.add_tool( + Tool( + name="judge", + network=True, + description="NETWORK: opt-in LLM triage of active defects via OpenRouter " + "(needs WARDLINE_OPENROUTER_API_KEY). Labels each TRUE/FALSE positive. " + "Never run automatically; never folded into scan.", + input_schema={ + "type": "object", + "properties": { + "config": {"type": "string"}, + "model": {"type": "string"}, + "max_findings": {"type": "integer"}, + "write": {"type": "boolean", "description": "append above-floor FPs to judged.yaml"}, + }, }, - }, - handler=lambda args, root: _explain_taint(args, root, self._clarion_client()), - )) - self.add_tool(Tool( - name="judge", network=True, - description="NETWORK: opt-in LLM triage of active defects via OpenRouter " - "(needs WARDLINE_OPENROUTER_API_KEY). Labels each TRUE/FALSE positive. " - "Never run automatically; never folded into scan.", - input_schema={"type": "object", "properties": { - "config": {"type": "string"}, "model": {"type": "string"}, - "max_findings": {"type": "integer"}, - "write": {"type": "boolean", "description": "append above-floor FPs to judged.yaml"}}}, - handler=_judge, - )) - self.add_tool(Tool( - name="baseline_create", - description="Snapshot current defects as the baseline so only NEW findings surface. " - "Prefer FIXING a finding over baselining it. Requires a reason.", - input_schema={"type": "object", "required": ["reason"], "properties": { - "reason": {"type": "string"}, "config": {"type": "string"}}}, - handler=_baseline_create, - )) - self.add_tool(Tool( - name="baseline_update", - description="Re-derive and OVERWRITE the baseline. Requires a reason.", - input_schema={"type": "object", "required": ["reason"], "properties": { - "reason": {"type": "string"}, "config": {"type": "string"}}}, - handler=_baseline_update, - )) - self.add_tool(Tool( - name="waiver_add", - description="Waive ONE finding by fingerprint with a mandatory reason and expiry. " - "Prefer fixing; a waiver is an audited, time-boxed exception.", - input_schema={"type": "object", "required": ["fingerprint", "reason", "expires"], - "properties": {"fingerprint": {"type": "string"}, - "reason": {"type": "string"}, - "expires": {"type": "string", "description": "YYYY-MM-DD"}}}, - handler=_waiver_add, - )) + handler=_judge, + ) + ) + self.add_tool( + Tool( + name="baseline_create", + description="Snapshot current defects as the baseline so only NEW findings surface. " + "Prefer FIXING a finding over baselining it. Requires a reason.", + input_schema={ + "type": "object", + "required": ["reason"], + "properties": {"reason": {"type": "string"}, "config": {"type": "string"}}, + }, + handler=_baseline_create, + ) + ) + self.add_tool( + Tool( + name="baseline_update", + description="Re-derive and OVERWRITE the baseline. Requires a reason.", + input_schema={ + "type": "object", + "required": ["reason"], + "properties": {"reason": {"type": "string"}, "config": {"type": "string"}}, + }, + handler=_baseline_update, + ) + ) + self.add_tool( + Tool( + name="waiver_add", + description="Waive ONE finding by fingerprint with a mandatory reason and expiry. " + "Prefer fixing; a waiver is an audited, time-boxed exception.", + input_schema={ + "type": "object", + "required": ["fingerprint", "reason", "expires"], + "properties": { + "fingerprint": {"type": "string"}, + "reason": {"type": "string"}, + "expires": {"type": "string", "description": "YYYY-MM-DD"}, + }, + }, + handler=_waiver_add, + ) + ) def add_tool(self, tool: Tool) -> None: self._tools[tool.name] = tool @@ -378,20 +413,25 @@ def _read_resource(self, uri: str | None) -> tuple[str, str]: # The human-meaningful description lives on the rule's METADATA, not # the class docstring (the rule classes carry module-level docstrings, # so cls.__doc__ is None) — use the real metadata field. - rules.append({ - "rule_id": inst.rule_id, - "base_severity": inst.base_severity.value, - "description": cls.metadata.description, - }) + rules.append( + { + "rule_id": inst.rule_id, + "base_severity": inst.base_severity.value, + "description": cls.metadata.description, + } + ) return json.dumps({"rules": rules}, ensure_ascii=False), "application/json" if uri == "wardline://config": cfg = config_mod.load(self.root / "wardline.yaml") - return json.dumps({ - "source_roots": list(cfg.source_roots), - "exclude": list(cfg.exclude), - "rules_enable": list(cfg.rules_enable), - "rules_severity": dict(cfg.rules_severity), - }, ensure_ascii=False), "application/json" + return json.dumps( + { + "source_roots": list(cfg.source_roots), + "exclude": list(cfg.exclude), + "rules_enable": list(cfg.rules_enable), + "rules_severity": dict(cfg.rules_severity), + }, + ensure_ascii=False, + ), "application/json" raise McpError(f"unknown resource: {uri}") def _wire(self) -> None: @@ -406,10 +446,12 @@ def _wire(self) -> None: self.rpc.register("prompts/get", self._prompts_get) def _tools_list(self, params: dict[str, Any]) -> dict[str, Any]: - return {"tools": [ - {"name": t.name, "description": t.description, "inputSchema": t.input_schema} - for t in self._tools.values() - ]} + return { + "tools": [ + {"name": t.name, "description": t.description, "inputSchema": t.input_schema} + for t in self._tools.values() + ] + } def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]: name = params.get("name") @@ -440,10 +482,7 @@ def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]: return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} def _resources_list(self, params: dict[str, Any]) -> dict[str, Any]: - return {"resources": [ - {"uri": uri, "name": name, "mimeType": mime} - for uri, name, mime in self._RESOURCES - ]} + return {"resources": [{"uri": uri, "name": name, "mimeType": mime} for uri, name, mime in self._RESOURCES]} def _resources_read(self, params: dict[str, Any]) -> dict[str, Any]: uri = params.get("uri") @@ -462,15 +501,15 @@ def _resources_read(self, params: dict[str, Any]) -> dict[str, Any]: ) def _prompts_list(self, params: dict[str, Any]) -> dict[str, Any]: - return {"prompts": [{"name": "wardline:loop", - "description": "The intended scan→explain→fix→rescan loop."}]} + return {"prompts": [{"name": "wardline:loop", "description": "The intended scan→explain→fix→rescan loop."}]} def _prompts_get(self, params: dict[str, Any]) -> dict[str, Any]: if params.get("name") != "wardline:loop": raise McpError(f"unknown prompt: {params.get('name')}") - return {"description": "The intended scan→explain→fix→rescan loop.", - "messages": [{"role": "user", - "content": {"type": "text", "text": self._LOOP_PROMPT}}]} + return { + "description": "The intended scan→explain→fix→rescan loop.", + "messages": [{"role": "user", "content": {"type": "text", "text": self._LOOP_PROMPT}}], + } @staticmethod def _is_error(text: str) -> dict[str, Any]: diff --git a/src/wardline/scanner/__init__.py b/src/wardline/scanner/__init__.py index b1e16ed1..7ca0f96a 100644 --- a/src/wardline/scanner/__init__.py +++ b/src/wardline/scanner/__init__.py @@ -13,9 +13,7 @@ class NoOpAnalyzer: """Placeholder analyzer that performs no analysis (SP0).""" - def analyze( - self, files: Sequence[Path], config: WardlineConfig, *, root: Path - ) -> Sequence[Finding]: + def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) -> Sequence[Finding]: return [] diff --git a/src/wardline/scanner/analyzer.py b/src/wardline/scanner/analyzer.py index fff31a3a..2dbbe539 100644 --- a/src/wardline/scanner/analyzer.py +++ b/src/wardline/scanner/analyzer.py @@ -68,14 +68,10 @@ def __init__( self._cache = summary_cache self.last_context: AnalysisContext | None = None - def analyze( - self, files: Sequence[Path], config: WardlineConfig, *, root: Path - ) -> Sequence[Finding]: + def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) -> Sequence[Finding]: modules: list[ModuleInput] = [] # (relpath, module_path, tree, entities, alias_map, class_qualnames) - file_meta: list[ - tuple[str, str, ast.Module, tuple[Entity, ...], dict[str, str], frozenset[str]] - ] = [] + file_meta: list[tuple[str, str, ast.Module, tuple[Entity, ...], dict[str, str], frozenset[str]]] = [] parse_findings: list[Finding] = [] # ``discover`` resolves the root to an absolute path, so the files it yields are @@ -86,11 +82,7 @@ def analyze( root = root.resolve() for path in files: - relpath = ( - path.relative_to(root).as_posix() - if path.is_relative_to(root) - else path.as_posix() - ) + relpath = path.relative_to(root).as_posix() if path.is_relative_to(root) else path.as_posix() module = module_dotted_name(relpath) if module is None: # The file was discovered but maps to no module (e.g. a top-level @@ -177,9 +169,7 @@ def analyze( dirty_modules=frozenset(), ) else: - result = resolve_project_taints( - modules=modules, provider_fingerprint=self._provider.fingerprint() - ) + result = resolve_project_taints(modules=modules, provider_fingerprint=self._provider.fingerprint()) # Measured AFTER resolve so it reflects THIS run's cache effectiveness # (0.0 cold, →1.0 warm). It is a genuinely run-varying METRIC; the @@ -199,7 +189,7 @@ def analyze( prefix = module + "." bucket = project_by_module.setdefault(module, {}) for ent in entities: - rest = ent.qualname[len(prefix):] if ent.qualname.startswith(prefix) else ent.qualname + rest = ent.qualname[len(prefix) :] if ent.qualname.startswith(prefix) else ent.qualname if "." not in rest: # top-level function (methods aren't bare-callable) bucket[rest] = project_return_taints.get(ent.qualname, TaintState.UNKNOWN_RAW) @@ -209,9 +199,7 @@ def analyze( entity_index: dict[str, Entity] = {} func_skip_findings: list[Finding] = [] for _relpath, module, _tree, entities, alias_map, classes in file_meta: - call_tm = build_call_taint_map( - module_path=module, alias_map=alias_map, project_by_module=project_by_module - ) + call_tm = build_call_taint_map(module_path=module, alias_map=alias_map, project_by_module=project_by_module) for ent in entities: entity_index[ent.qualname] = ent seed = project_taints.get(ent.qualname, TaintState.UNKNOWN_RAW) @@ -229,14 +217,9 @@ def analyze( if enclosing_class in classes: sib_prefix = enclosing_class + "." for sib in entities: - if ( - sib.qualname.startswith(sib_prefix) - and "." not in sib.qualname[len(sib_prefix):] - ): - sib_name = sib.qualname[len(sib_prefix):] - sib_taint = project_return_taints.get( - sib.qualname, TaintState.UNKNOWN_RAW - ) + if sib.qualname.startswith(sib_prefix) and "." not in sib.qualname[len(sib_prefix) :]: + sib_name = sib.qualname[len(sib_prefix) :] + sib_taint = project_return_taints.get(sib.qualname, TaintState.UNKNOWN_RAW) method_tm[f"self.{sib_name}"] = sib_taint method_tm[f"cls.{sib_name}"] = sib_taint try: @@ -247,9 +230,7 @@ def analyze( # function_var_taints. A forward-referencing walrus inside a return # would otherwise get a second, non-idempotent resolve pass that # perturbs the stored map. Same starting state ⇒ same ret_callee. - ret_callee = compute_return_callee( - ent.node, seed, dict(method_tm), dict(var_taints) - ) + ret_callee = compute_return_callee(ent.node, seed, dict(method_tm), dict(var_taints)) except RecursionError: # Fail-closed: absent vars read as the function taint, and the # return taint is unknown. Emit a FACT so the gap is observable diff --git a/src/wardline/scanner/ast_primitives.py b/src/wardline/scanner/ast_primitives.py index aaaf40a9..a5d3748b 100644 --- a/src/wardline/scanner/ast_primitives.py +++ b/src/wardline/scanner/ast_primitives.py @@ -43,12 +43,8 @@ def build_import_alias_map( for node in ast.iter_child_nodes(tree): if isinstance(node, ast.Import): for alias in node.names: - local_name = ( - alias.asname if alias.asname else alias.name.split(".")[0] - ) - alias_map[local_name] = ( - alias.name if alias.asname else alias.name.split(".")[0] - ) + local_name = alias.asname if alias.asname else alias.name.split(".")[0] + alias_map[local_name] = alias.name if alias.asname else alias.name.split(".")[0] continue if isinstance(node, ast.ImportFrom): if node.module is None and (node.level or 0) == 0: @@ -72,11 +68,7 @@ def build_import_alias_map( base_parts = [] base = ".".join(base_parts) if node.module: - fqn = ( - f"{base}.{node.module}.{alias.name}" - if base - else f"{node.module}.{alias.name}" - ) + fqn = f"{base}.{node.module}.{alias.name}" if base else f"{node.module}.{alias.name}" else: fqn = f"{base}.{alias.name}" if base else alias.name elif node.module is not None: @@ -156,11 +148,7 @@ def resolve_self_method_fqn( if caller_class_fqn is None: return None func = call.func - if ( - isinstance(func, ast.Attribute) - and isinstance(func.value, ast.Name) - and func.value.id in {"self", "cls"} - ): + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id in {"self", "cls"}: candidate = f"{caller_class_fqn}.{func.attr}" if candidate in project_fqns: return candidate diff --git a/src/wardline/scanner/context.py b/src/wardline/scanner/context.py index be665be9..5f8801da 100644 --- a/src/wardline/scanner/context.py +++ b/src/wardline/scanner/context.py @@ -51,22 +51,12 @@ class AnalysisContext: def __post_init__(self) -> None: object.__setattr__(self, "project_taints", MappingProxyType(dict(self.project_taints))) - object.__setattr__( - self, "project_return_taints", MappingProxyType(dict(self.project_return_taints)) - ) - object.__setattr__( - self, "function_var_taints", MappingProxyType(dict(self.function_var_taints)) - ) - object.__setattr__( - self, "function_return_taints", MappingProxyType(dict(self.function_return_taints)) - ) - object.__setattr__( - self, "function_return_callee", MappingProxyType(dict(self.function_return_callee)) - ) + object.__setattr__(self, "project_return_taints", MappingProxyType(dict(self.project_return_taints))) + object.__setattr__(self, "function_var_taints", MappingProxyType(dict(self.function_var_taints))) + object.__setattr__(self, "function_return_taints", MappingProxyType(dict(self.function_return_taints))) + object.__setattr__(self, "function_return_callee", MappingProxyType(dict(self.function_return_callee))) object.__setattr__(self, "entities", MappingProxyType(dict(self.entities))) - object.__setattr__( - self, "taint_provenance", MappingProxyType(dict(self.taint_provenance)) - ) + object.__setattr__(self, "taint_provenance", MappingProxyType(dict(self.taint_provenance))) class _Rule(Protocol): diff --git a/src/wardline/scanner/diagnostics.py b/src/wardline/scanner/diagnostics.py index 32ee7fe0..ac0b2081 100644 --- a/src/wardline/scanner/diagnostics.py +++ b/src/wardline/scanner/diagnostics.py @@ -36,9 +36,7 @@ def _fingerprint(*parts: str) -> str: return digest.hexdigest() -def build_metric_finding( - metadata: ResolverRunMetadata, *, cache_hit_rate: float -) -> Finding: +def build_metric_finding(metadata: ResolverRunMetadata, *, cache_hit_rate: float) -> Finding: """One METRIC finding carrying the L3 run metrics. Fingerprint is keyed on metric IDENTITY (fixed), since the values drift run to run.""" return Finding( @@ -51,9 +49,7 @@ def build_metric_finding( properties={ "scc_size_distribution": [list(p) for p in metadata.scc_size_distribution], "convergence_iterations_max": metadata.convergence_iterations_max, - "convergence_iterations_histogram": [ - list(p) for p in metadata.convergence_iterations_histogram - ], + "convergence_iterations_histogram": [list(p) for p in metadata.convergence_iterations_histogram], "taint_source_counts": dict(metadata.taint_source_counts), "cache_hit_rate": cache_hit_rate, }, @@ -98,8 +94,10 @@ def build_unknown_import_findings( stdlib_keys = stdlib_taint_keys() # suppress curated stdlib entries (forward-correct) for relpath, module_path, tree in file_trees: for _mp, detail, reason in diagnose_unknown_imports( - tree=tree, module_path=module_path, - project_modules=project_modules, stdlib_keys=stdlib_keys, + tree=tree, + module_path=module_path, + project_modules=project_modules, + stdlib_keys=stdlib_keys, ): package = detail.split()[1] if detail.startswith("from ") else detail findings.append( @@ -134,14 +132,11 @@ def _is_type_checking_guarded(node: ast.AST, tree: ast.Module) -> bool: if not isinstance(top, ast.If): continue test = top.test - is_tc = ( - (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") - or ( - isinstance(test, ast.Attribute) - and isinstance(test.value, ast.Name) - and test.value.id == "typing" - and test.attr == "TYPE_CHECKING" - ) + is_tc = (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) + and isinstance(test.value, ast.Name) + and test.value.id == "typing" + and test.attr == "TYPE_CHECKING" ) if not is_tc: continue @@ -235,11 +230,13 @@ def diagnose_unknown_imports( key = (module_path, mod) if key not in seen: seen.add(key) - findings.append(( - module_path, - f"from {mod} import *", - f"star import from external package {mod!r} cannot be materialised", - )) + findings.append( + ( + module_path, + f"from {mod} import *", + f"star import from external package {mod!r} cannot be materialised", + ) + ) continue # Named-import branch — dedupe by (module_path, mod). @@ -255,10 +252,11 @@ def diagnose_unknown_imports( alias_preview = ", ".join(unresolved_aliases[:3]) if len(unresolved_aliases) > 3: alias_preview += f", ... ({len(unresolved_aliases)} total)" - findings.append(( - module_path, - f"from {mod} import {alias_preview}", - f"external import from {mod!r} cannot be resolved " - f"(aliases: {alias_preview})", - )) + findings.append( + ( + module_path, + f"from {mod} import {alias_preview}", + f"external import from {mod!r} cannot be resolved (aliases: {alias_preview})", + ) + ) return findings diff --git a/src/wardline/scanner/index.py b/src/wardline/scanner/index.py index 159f294a..c3a36189 100644 --- a/src/wardline/scanner/index.py +++ b/src/wardline/scanner/index.py @@ -55,9 +55,7 @@ def visit(node: ast.AST, scope: list[ast.AST]) -> None: return classes -def discover_file_entities( - tree: ast.Module, *, module: str, path: str -) -> list[Entity]: +def discover_file_entities(tree: ast.Module, *, module: str, path: str) -> list[Entity]: """Discover function/method entities in *tree*, in source order. Args: diff --git a/src/wardline/scanner/rules/_ast_helpers.py b/src/wardline/scanner/rules/_ast_helpers.py index 7e20f54e..1ae12270 100644 --- a/src/wardline/scanner/rules/_ast_helpers.py +++ b/src/wardline/scanner/rules/_ast_helpers.py @@ -59,21 +59,14 @@ def is_broad_except(handler: ast.ExceptHandler) -> bool: def _is_ellipsis(stmt: ast.stmt) -> bool: - return ( - isinstance(stmt, ast.Expr) - and isinstance(stmt.value, ast.Constant) - and stmt.value.value is Ellipsis - ) + return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and stmt.value.value is Ellipsis def is_silent_handler(handler: ast.ExceptHandler) -> bool: """True when the handler body only swallows: every statement is ``pass``, ``...``, ``continue``, or ``break`` (no logging, re-raise, return, or other handling).""" - return all( - isinstance(stmt, (ast.Pass, ast.Continue, ast.Break)) or _is_ellipsis(stmt) - for stmt in handler.body - ) + return all(isinstance(stmt, (ast.Pass, ast.Continue, ast.Break)) or _is_ellipsis(stmt) for stmt in handler.body) def _is_falsy_constant_return(value: ast.expr | None) -> bool: diff --git a/src/wardline/scanner/rules/boundary_without_rejection.py b/src/wardline/scanner/rules/boundary_without_rejection.py index f5589ce2..b1a21094 100644 --- a/src/wardline/scanner/rules/boundary_without_rejection.py +++ b/src/wardline/scanner/rules/boundary_without_rejection.py @@ -32,8 +32,7 @@ ), examples_violation=("@trust_boundary(to_level='ASSURED')\ndef v(p):\n return p",), examples_clean=( - "@trust_boundary(to_level='ASSURED')\n" - "def v(p):\n if not p:\n raise ValueError\n return p", + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n if not p:\n raise ValueError\n return p", ), ) diff --git a/src/wardline/scanner/rules/broad_exception.py b/src/wardline/scanner/rules/broad_exception.py index 610af889..369a387b 100644 --- a/src/wardline/scanner/rules/broad_exception.py +++ b/src/wardline/scanner/rules/broad_exception.py @@ -25,8 +25,7 @@ rule_id="PY-WL-103", base_severity=Severity.WARN, kind=Kind.DEFECT, - description="A broad exception handler (bare except / Exception / BaseException) " - "in a trusted-tier function.", + description="A broad exception handler (bare except / Exception / BaseException) in a trusted-tier function.", examples_violation=("@trusted\ndef f():\n try:\n g()\n except Exception:\n h()",), examples_clean=("@trusted\ndef f():\n try:\n g()\n except ValueError:\n h()",), ) diff --git a/src/wardline/scanner/rules/untrusted_reaches_trusted.py b/src/wardline/scanner/rules/untrusted_reaches_trusted.py index 00a2ee1e..84532c2a 100644 --- a/src/wardline/scanner/rules/untrusted_reaches_trusted.py +++ b/src/wardline/scanner/rules/untrusted_reaches_trusted.py @@ -52,9 +52,7 @@ # are what preserve the invariant that this asymmetry stays latent. See # docs/decisions/2026-05-31-wardline-taint-lattice-retain.md and # docs/concepts/taint-algebra.md. -_RAW_ZONE: frozenset[TaintState] = frozenset( - {TaintState.EXTERNAL_RAW, TaintState.UNKNOWN_RAW, TaintState.MIXED_RAW} -) +_RAW_ZONE: frozenset[TaintState] = frozenset({TaintState.EXTERNAL_RAW, TaintState.UNKNOWN_RAW, TaintState.MIXED_RAW}) METADATA = RuleMetadata( rule_id="PY-WL-101", diff --git a/src/wardline/scanner/taint/call_taint_map.py b/src/wardline/scanner/taint/call_taint_map.py index 43defff2..c9013e1e 100644 --- a/src/wardline/scanner/taint/call_taint_map.py +++ b/src/wardline/scanner/taint/call_taint_map.py @@ -75,20 +75,16 @@ def build_call_taint_map( # (d) stdlib_taint with the serialisation-sink override. stdlib = load_stdlib_taint() for (pkg, fn), entry in stdlib.items(): - value = ( - TaintState.UNKNOWN_RAW - if f"{pkg}.{fn}" in _SERIALISATION_SINKS - else entry.taint - ) + value = TaintState.UNKNOWN_RAW if f"{pkg}.{fn}" in _SERIALISATION_SINKS else entry.taint for local, target in alias_map.items(): if target == pkg: - tm.setdefault(f"{local}.{fn}", value) # import pkg [as local] + tm.setdefault(f"{local}.{fn}", value) # import pkg [as local] elif target == f"{pkg}.{fn}": - tm.setdefault(local, value) # from pkg import fn [as local] + tm.setdefault(local, value) # from pkg import fn [as local] elif pkg.startswith(target + "."): # ``import top.sub`` collapses the alias to ``top``; the call is # written ``local..fn`` (e.g. urllib.request.urlopen). - remainder = pkg[len(target) + 1:] + remainder = pkg[len(target) + 1 :] tm.setdefault(f"{local}.{remainder}.{fn}", value) # (e) Serialisation-sink alias closure. The override in (d) only fires for @@ -106,11 +102,11 @@ def build_call_taint_map( continue for local, target in alias_map.items(): if target == pkg: - tm.setdefault(f"{local}.{fn}", TaintState.UNKNOWN_RAW) # import pkg [as local] + tm.setdefault(f"{local}.{fn}", TaintState.UNKNOWN_RAW) # import pkg [as local] elif target == sink: - tm.setdefault(local, TaintState.UNKNOWN_RAW) # from pkg import fn [as local] + tm.setdefault(local, TaintState.UNKNOWN_RAW) # from pkg import fn [as local] elif pkg.startswith(target + "."): - remainder = pkg[len(target) + 1:] + remainder = pkg[len(target) + 1 :] tm.setdefault(f"{local}.{remainder}.{fn}", TaintState.UNKNOWN_RAW) return tm diff --git a/src/wardline/scanner/taint/decorator_provider.py b/src/wardline/scanner/taint/decorator_provider.py index 7a36cc15..abfa74fd 100644 --- a/src/wardline/scanner/taint/decorator_provider.py +++ b/src/wardline/scanner/taint/decorator_provider.py @@ -145,16 +145,17 @@ def _match(self, deco: ast.expr, alias_map: Mapping[str, str]) -> FunctionTaint if canonical == "external_boundary": return FunctionTaint(TaintState.EXTERNAL_RAW, TaintState.EXTERNAL_RAW) if canonical == "trust_boundary": - to_level = _read_level( - deco, "to_level", allowed=_BOUNDARY_LEVELS, default=None, alias_map=alias_map - ) + to_level = _read_level(deco, "to_level", allowed=_BOUNDARY_LEVELS, default=None, alias_map=alias_map) if to_level is None: return None return FunctionTaint(TaintState.EXTERNAL_RAW, to_level) if canonical == "trusted": level = _read_level( - deco, "level", allowed=_TRUSTED_LEVELS, - default=TaintState.INTEGRAL, alias_map=alias_map, + deco, + "level", + allowed=_TRUSTED_LEVELS, + default=TaintState.INTEGRAL, + alias_map=alias_map, ) if level is None: return None diff --git a/src/wardline/scanner/taint/minimum_scope.py b/src/wardline/scanner/taint/minimum_scope.py index ed69325f..48efab38 100644 --- a/src/wardline/scanner/taint/minimum_scope.py +++ b/src/wardline/scanner/taint/minimum_scope.py @@ -61,9 +61,7 @@ def build_minimum_scope_edges( resolved: set[str] = set() unresolved = 0 for call in iter_calls_in_function_body(entity.node): - callee_fqn = resolve_call_fqn( - call, fd.import_aliases, local_fqns, fd.module_path - ) + callee_fqn = resolve_call_fqn(call, fd.import_aliases, local_fqns, fd.module_path) if callee_fqn is not None and callee_fqn in global_fqns: resolved.add(callee_fqn) else: @@ -120,9 +118,7 @@ def _anchor_or_seed(func: str) -> TaintState: return return_taints.get(func, _seed(func)) return _seed(func) - def _refine( - func: str, *, remaining_intermediaries: int, stack: frozenset[str] - ) -> tuple[TaintState, str | None]: + def _refine(func: str, *, remaining_intermediaries: int, stack: frozenset[str]) -> tuple[TaintState, str | None]: seed = _seed(func) if remaining_intermediaries < 0: return seed, None @@ -171,9 +167,7 @@ def _refine( seed = _opt_seed(func) if seed is None: continue - refined_taint, via_callee = _refine( - func, remaining_intermediaries=1, stack=frozenset({func}) - ) + refined_taint, via_callee = _refine(func, remaining_intermediaries=1, stack=frozenset({func})) refined[func] = refined_taint if refined_taint != seed: provenance[func] = MinimumScopeProvenance( diff --git a/src/wardline/scanner/taint/project_resolver.py b/src/wardline/scanner/taint/project_resolver.py index d9e2ebb0..d586859c 100644 --- a/src/wardline/scanner/taint/project_resolver.py +++ b/src/wardline/scanner/taint/project_resolver.py @@ -154,16 +154,11 @@ def resolve_project_taints( # validated output is mis-read as its raw body taint (an over-taint that # false-positives PY-WL-101). effective_return: dict[str, TaintState] = { - fqn: (return_taint_map[fqn] if taint_sources.get(fqn) == "anchored" else refined[fqn]) - for fqn in refined + fqn: (return_taint_map[fqn] if taint_sources.get(fqn) == "anchored" else refined[fqn]) for fqn in refined } - scc_size_distribution = tuple( - sorted(Counter(len(k) for k in scc_iteration_counts).items()) - ) - convergence_iterations_histogram = tuple( - sorted(Counter(scc_iteration_counts.values()).items()) - ) + scc_size_distribution = tuple(sorted(Counter(len(k) for k in scc_iteration_counts).items())) + convergence_iterations_histogram = tuple(sorted(Counter(scc_iteration_counts.values()).items())) convergence_iterations_max = max(scc_iteration_counts.values(), default=0) taint_source_counts = Counter(taint_sources.values()) metadata = ResolverRunMetadata( @@ -180,9 +175,7 @@ def resolve_project_taints( return ResolverResult( taint_map=MappingProxyType(refined), return_taint_map=MappingProxyType(effective_return), - project_edges=MappingProxyType( - {fqn: frozenset(callees) for fqn, callees in edges.items()} - ), + project_edges=MappingProxyType({fqn: frozenset(callees) for fqn, callees in edges.items()}), taint_provenance=MappingProxyType(dict(provenance)), diagnostics=tuple(diagnostics), metadata=metadata, diff --git a/src/wardline/scanner/taint/propagation.py b/src/wardline/scanner/taint/propagation.py index 48f74b0a..e4c44b1f 100644 --- a/src/wardline/scanner/taint/propagation.py +++ b/src/wardline/scanner/taint/propagation.py @@ -235,8 +235,8 @@ def propagate_callgraph_taints( # --- 1. Classify functions ------------------------------------------------ anchored: set[str] = set() - floating_down: set[str] = set() # module_default - floating_free: set[str] = set() # fallback + floating_down: set[str] = set() # module_default + floating_free: set[str] = set() # fallback for func, src in taint_sources.items(): if src == "anchored": @@ -426,10 +426,12 @@ def propagate_callgraph_taints( len(scc), iterations, ) - diagnostics.append(( - DIAG_CONVERGENCE_BOUND, - f"SCC of size {len(scc)} hit iteration bound after {iterations} iterations", - )) + diagnostics.append( + ( + DIAG_CONVERGENCE_BOUND, + f"SCC of size {len(scc)} hit iteration bound after {iterations} iterations", + ) + ) break updates, via_candidates = _compute_scc_round( @@ -457,13 +459,15 @@ def propagate_callgraph_taints( old_taint=current[func], new_taint=new_taint, ): - diagnostics.append(( - DIAG_MONOTONICITY_VIOLATION, - f"function {func!r} moved from {current[func].value} " - f"(rank {TRUST_RANK[current[func]]}) to {new_taint.value} " - f"(rank {TRUST_RANK[new_taint]}) " - f"without anchor — violates monotone fixed point", - )) + diagnostics.append( + ( + DIAG_MONOTONICITY_VIOLATION, + f"function {func!r} moved from {current[func].value} " + f"(rank {TRUST_RANK[current[func]]}) to {new_taint.value} " + f"(rank {TRUST_RANK[new_taint]}) " + f"without anchor — violates monotone fixed point", + ) + ) # Pin to the old (less-trusted) value; skip the commit. continue current[func] = new_taint @@ -492,9 +496,17 @@ def propagate_callgraph_taints( taint_map[func], current[func], ) - return dict(taint_map), _seed_provenance_only( - taint_map, taint_sources, resolved_counts, unresolved_counts, - ), diagnostics, scc_iteration_counts + return ( + dict(taint_map), + _seed_provenance_only( + taint_map, + taint_sources, + resolved_counts, + unresolved_counts, + ), + diagnostics, + scc_iteration_counts, + ) # TRUST_RANK: ordering assertion, not taint combination (see §6) for func in floating_down: @@ -505,9 +517,17 @@ def propagate_callgraph_taints( taint_map[func], current[func], ) - return dict(taint_map), _seed_provenance_only( - taint_map, taint_sources, resolved_counts, unresolved_counts, - ), diagnostics, scc_iteration_counts + return ( + dict(taint_map), + _seed_provenance_only( + taint_map, + taint_sources, + resolved_counts, + unresolved_counts, + ), + diagnostics, + scc_iteration_counts, + ) # --- 6b. L3_LOW_RESOLUTION detection -------------------------------------- for func in taint_map: @@ -524,11 +544,12 @@ def propagate_callgraph_taints( unresolved_ratio = unres / total_calls if unresolved_ratio > L3_LOW_RESOLUTION_THRESHOLD: pct = int(unresolved_ratio * 100) - diagnostics.append(( - DIAG_LOW_RESOLUTION, - f"Function {func} has {pct}% unresolved calls " - f"({unres}/{total_calls})", - )) + diagnostics.append( + ( + DIAG_LOW_RESOLUTION, + f"Function {func} has {pct}% unresolved calls ({unres}/{total_calls})", + ) + ) # --- 7. Build provenance records ------------------------------------------ provenance: dict[str, TaintProvenance] = {} diff --git a/src/wardline/scanner/taint/resolver_metadata.py b/src/wardline/scanner/taint/resolver_metadata.py index f00df9ad..9c6b1a1d 100644 --- a/src/wardline/scanner/taint/resolver_metadata.py +++ b/src/wardline/scanner/taint/resolver_metadata.py @@ -40,9 +40,7 @@ class ResolverRunMetadata: def __post_init__(self) -> None: if self.convergence_iterations_max < 0: - raise ValueError( - f"convergence_iterations_max must be >= 0, got {self.convergence_iterations_max}" - ) + raise ValueError(f"convergence_iterations_max must be >= 0, got {self.convergence_iterations_max}") for name, hist in ( ("scc_size_distribution", self.scc_size_distribution), ("convergence_iterations_histogram", self.convergence_iterations_histogram), @@ -52,9 +50,7 @@ def __post_init__(self) -> None: for _bucket, count in hist: if count < 1: raise ValueError(f"{name} counts must be >= 1; got {count}") - object.__setattr__( - self, "taint_source_counts", MappingProxyType(dict(self.taint_source_counts)) - ) + object.__setattr__(self, "taint_source_counts", MappingProxyType(dict(self.taint_source_counts))) @dataclass(frozen=True, slots=True, kw_only=True) @@ -79,10 +75,6 @@ class ResolverResult: def __post_init__(self) -> None: object.__setattr__(self, "taint_map", MappingProxyType(dict(self.taint_map))) - object.__setattr__( - self, "return_taint_map", MappingProxyType(dict(self.return_taint_map)) - ) + object.__setattr__(self, "return_taint_map", MappingProxyType(dict(self.return_taint_map))) object.__setattr__(self, "project_edges", MappingProxyType(dict(self.project_edges))) - object.__setattr__( - self, "taint_provenance", MappingProxyType(dict(self.taint_provenance)) - ) + object.__setattr__(self, "taint_provenance", MappingProxyType(dict(self.taint_provenance))) diff --git a/src/wardline/scanner/taint/stdlib_taint.py b/src/wardline/scanner/taint/stdlib_taint.py index 3eda9caa..d6989040 100644 --- a/src/wardline/scanner/taint/stdlib_taint.py +++ b/src/wardline/scanner/taint/stdlib_taint.py @@ -60,9 +60,7 @@ def _build_table(raw: Any) -> StdlibTaintTable: """ if not isinstance(raw, dict) or raw.get("version") != STDLIB_TAINT_VERSION: got = raw.get("version") if isinstance(raw, dict) else raw - raise ValueError( - f"stdlib_taint.yaml version mismatch: expected {STDLIB_TAINT_VERSION}, got {got!r}" - ) + raise ValueError(f"stdlib_taint.yaml version mismatch: expected {STDLIB_TAINT_VERSION}, got {got!r}") entries = raw.get("entries") if not isinstance(entries, list): raise ValueError("stdlib_taint.yaml: 'entries' must be a list") @@ -85,8 +83,7 @@ def _build_table(raw: Any) -> StdlibTaintTable: taint = TaintState(returns_taint_raw) except ValueError as exc: raise ValueError( - f"stdlib_taint.yaml entries[{idx}].returns_taint={returns_taint_raw!r} " - f"is not a canonical TaintState" + f"stdlib_taint.yaml entries[{idx}].returns_taint={returns_taint_raw!r} is not a canonical TaintState" ) from exc # Reject any state outside the stdlib-legal return set — both the # unreachable trio AND INTEGRAL (a stdlib call cannot produce your own diff --git a/src/wardline/scanner/taint/summary.py b/src/wardline/scanner/taint/summary.py index 00c8e6bc..5641553b 100644 --- a/src/wardline/scanner/taint/summary.py +++ b/src/wardline/scanner/taint/summary.py @@ -50,9 +50,7 @@ def __post_init__(self) -> None: f"SUMMARY_SCHEMA_VERSION={SUMMARY_SCHEMA_VERSION} — purge cache or upgrade" ) if self.unresolved_calls < 0: - raise ValueError( - f"unresolved_calls must be non-negative, got {self.unresolved_calls}" - ) + raise ValueError(f"unresolved_calls must be non-negative, got {self.unresolved_calls}") def compute_cache_key( diff --git a/src/wardline/scanner/taint/summary_cache.py b/src/wardline/scanner/taint/summary_cache.py index 8080a63c..bf58eb6b 100644 --- a/src/wardline/scanner/taint/summary_cache.py +++ b/src/wardline/scanner/taint/summary_cache.py @@ -139,8 +139,7 @@ def put(self, cache_key: str, summaries: tuple[FunctionSummary, ...]) -> None: """ if not self._CACHE_KEY_PATTERN.fullmatch(cache_key): raise ValueError( - f"SummaryCache.put rejected cache_key={cache_key!r} — expected " - f"64-char lowercase hex sha256 digest" + f"SummaryCache.put rejected cache_key={cache_key!r} — expected 64-char lowercase hex sha256 digest" ) for s in summaries: if s.schema_version != SUMMARY_SCHEMA_VERSION: @@ -178,7 +177,11 @@ def save(self) -> None: # Opened outside a `with` so temp_path is known for cleanup-on-failure; # the `with tf:` below is the actual context manager. (SIM115) tf = tempfile.NamedTemporaryFile( # noqa: SIM115 - mode="w", encoding="utf-8", dir=self._cache_dir, delete=False, suffix=".tmp", + mode="w", + encoding="utf-8", + dir=self._cache_dir, + delete=False, + suffix=".tmp", ) temp_path = Path(tf.name) try: diff --git a/src/wardline/scanner/taint/variable_level.py b/src/wardline/scanner/taint/variable_level.py index d0bbe134..22a8a6af 100644 --- a/src/wardline/scanner/taint/variable_level.py +++ b/src/wardline/scanner/taint/variable_level.py @@ -32,12 +32,29 @@ # wins.) _SERIALISATION_SINKS: frozenset[str] = frozenset( { - "json.dumps", "json.dump", "json.loads", "json.load", - "pickle.dumps", "pickle.dump", "pickle.loads", "pickle.load", - "yaml.dump", "yaml.safe_dump", "yaml.dump_all", - "yaml.safe_load", "yaml.load", "yaml.safe_load_all", "yaml.load_all", - "marshal.dumps", "marshal.dump", "marshal.loads", "marshal.load", - "tomllib.loads", "tomllib.load", "tomli_w.dumps", "tomli_w.dump", + "json.dumps", + "json.dump", + "json.loads", + "json.load", + "pickle.dumps", + "pickle.dump", + "pickle.loads", + "pickle.load", + "yaml.dump", + "yaml.safe_dump", + "yaml.dump_all", + "yaml.safe_load", + "yaml.load", + "yaml.safe_load_all", + "yaml.load_all", + "marshal.dumps", + "marshal.dump", + "marshal.loads", + "marshal.load", + "tomllib.loads", + "tomllib.load", + "tomli_w.dumps", + "tomli_w.dump", } ) @@ -46,9 +63,7 @@ # unaffected and there is no false-positive explosion). These return the join of # their argument taints: a string conversion / iterator advance carries whatever # taint went in. ``next`` closes the ``next(genexp)`` shape (the iterator arg). -_PROPAGATING_BUILTINS: frozenset[str] = frozenset( - {"str", "repr", "ascii", "bytes", "bytearray", "format", "next"} -) +_PROPAGATING_BUILTINS: frozenset[str] = frozenset({"str", "repr", "ascii", "bytes", "bytearray", "format", "next"}) # Curated taint-PROPAGATING methods, keyed by attribute name. ``.format``/``.join`` # combine the receiver with the arguments (``"sep".join(parts)`` carries both); @@ -143,11 +158,7 @@ def _resolve_expr( return result if isinstance(node, ast.Dict): # Container summary = weakest-link of its values (least_trusted). - parts = [ - _resolve_expr(v, function_taint, taint_map, var_taints) - for v in node.values - if v is not None - ] + parts = [_resolve_expr(v, function_taint, taint_map, var_taints) for v in node.values if v is not None] if not parts: return TaintState.INTEGRAL result = parts[0] @@ -186,9 +197,7 @@ def _resolve_expr( # clean, a raw value still propagates. result = _resolve_expr(node.values[0], function_taint, taint_map, var_taints) for value in node.values[1:]: - result = least_trusted( - result, _resolve_expr(value, function_taint, taint_map, var_taints) - ) + result = least_trusted(result, _resolve_expr(value, function_taint, taint_map, var_taints)) return result if isinstance(node, ast.JoinedStr): return _resolve_joined_str(node, function_taint, taint_map, var_taints) @@ -285,11 +294,7 @@ def _name_bound_by_walrus(node: ast.AST, name: str) -> bool: for child in ast.iter_child_nodes(node): if isinstance(child, ast.Lambda): continue - if ( - isinstance(child, ast.NamedExpr) - and isinstance(child.target, ast.Name) - and child.target.id == name - ): + if isinstance(child, ast.NamedExpr) and isinstance(child.target, ast.Name) and child.target.id == name: return True if _name_bound_by_walrus(child, name): return True @@ -306,13 +311,8 @@ def _resolve_call( # ``foo(x := bar())`` must bind ``x`` even when the call's own taint comes # from ``node.func`` — AND captures the arg taints for the curated # taint-PROPAGATING ops below (str(raw), "{}".format(raw), etc.). - arg_taints = [ - _resolve_expr(arg, function_taint, taint_map, var_taints) for arg in node.args - ] - arg_taints += [ - _resolve_expr(keyword.value, function_taint, taint_map, var_taints) - for keyword in node.keywords - ] + arg_taints = [_resolve_expr(arg, function_taint, taint_map, var_taints) for arg in node.args] + arg_taints += [_resolve_expr(keyword.value, function_taint, taint_map, var_taints) for keyword in node.keywords] if isinstance(node.func, ast.Attribute): dotted = _dotted_name(node.func) if dotted is not None: @@ -485,14 +485,21 @@ def _handle_assign( if isinstance(target, ast.Name): # Simple: x = expr taint = _resolve_expr( - stmt.value, function_taint, taint_map, var_taints, + stmt.value, + function_taint, + taint_map, + var_taints, ) var_taints[target.id] = taint elif isinstance(target, (ast.Tuple, ast.List)): # Tuple unpacking: a, b = ... _handle_unpack( - target, stmt.value, function_taint, taint_map, var_taints, + target, + stmt.value, + function_taint, + taint_map, + var_taints, ) elif isinstance(target, (ast.Subscript, ast.Attribute)): @@ -514,13 +521,14 @@ def _handle_unpack( ) -> None: """Handle tuple/list unpacking assignment.""" # If value is a Tuple/List with matching length, do element-wise. - if isinstance(value, (ast.Tuple, ast.List)) and len(value.elts) == len( - target.elts - ): + if isinstance(value, (ast.Tuple, ast.List)) and len(value.elts) == len(target.elts): for tgt, val in zip(target.elts, value.elts, strict=False): if isinstance(tgt, ast.Name): taint = _resolve_expr( - val, function_taint, taint_map, var_taints, + val, + function_taint, + taint_map, + var_taints, ) var_taints[tgt.id] = taint elif isinstance(tgt, (ast.Tuple, ast.List)): @@ -528,14 +536,15 @@ def _handle_unpack( else: # RHS is not a matching literal tuple — all targets get RHS taint. rhs_taint = _resolve_expr( - value, function_taint, taint_map, var_taints, + value, + function_taint, + taint_map, + var_taints, ) for tgt in target.elts: if isinstance(tgt, ast.Name): var_taints[tgt.id] = rhs_taint - elif isinstance(tgt, ast.Starred) and isinstance( - tgt.value, ast.Name - ): + elif isinstance(tgt, ast.Starred) and isinstance(tgt.value, ast.Name): var_taints[tgt.value.id] = rhs_taint @@ -553,7 +562,10 @@ def _handle_augassign( propagates. """ rhs_taint = _resolve_expr( - stmt.value, function_taint, taint_map, var_taints, + stmt.value, + function_taint, + taint_map, + var_taints, ) if isinstance(stmt.target, ast.Name): existing = var_taints.get(stmt.target.id, function_taint) @@ -654,7 +666,10 @@ def _handle_for( ) -> None: """Handle for loops — target gets iterable taint, body merges.""" iter_taint = _resolve_expr( - stmt.iter, function_taint, taint_map, var_taints, + stmt.iter, + function_taint, + taint_map, + var_taints, ) # Assign the loop variable. @@ -722,7 +737,10 @@ def _handle_with( """Handle with/async-with statements.""" for item in stmt.items: expr_taint = _resolve_expr( - item.context_expr, function_taint, taint_map, var_taints, + item.context_expr, + function_taint, + taint_map, + var_taints, ) if item.optional_vars is not None: _assign_target(item.optional_vars, expr_taint, var_taints) @@ -915,9 +933,7 @@ def compute_return_taint( (the function's anchored *body* taint, pinned to its declaration). """ returns: list[tuple[TaintState, str | None]] = [] - _collect_return_paths( - list(func_node.body), function_taint, taint_map, var_taints, returns - ) + _collect_return_paths(list(func_node.body), function_taint, taint_map, var_taints, returns) if not returns: return None result = returns[0][0] @@ -950,9 +966,7 @@ def compute_return_callee( always equals at least one collected path's taint, so the match is well-defined. """ returns: list[tuple[TaintState, str | None]] = [] - _collect_return_paths( - list(func_node.body), function_taint, taint_map, var_taints, returns - ) + _collect_return_paths(list(func_node.body), function_taint, taint_map, var_taints, returns) if not returns: return None worst = returns[0][0] @@ -1001,6 +1015,4 @@ def _collect_return_paths( if isinstance(node, ast.Return) and node.value is not None: taint = _resolve_expr(node.value, function_taint, taint_map, var_taints) out.append((taint, _return_callee(node.value))) - _collect_return_paths( - list(ast.iter_child_nodes(node)), function_taint, taint_map, var_taints, out - ) + _collect_return_paths(list(ast.iter_child_nodes(node)), function_taint, taint_map, var_taints, out) diff --git a/tests/conformance/test_clarion_qualname_parity.py b/tests/conformance/test_clarion_qualname_parity.py index 2144a915..ae28659d 100644 --- a/tests/conformance/test_clarion_qualname_parity.py +++ b/tests/conformance/test_clarion_qualname_parity.py @@ -17,14 +17,10 @@ from wardline.core.qualname import module_dotted_name -_FIXTURE = json.loads( - (Path(__file__).parent / "clarion_qualname_parity.json").read_text("utf-8") -) +_FIXTURE = json.loads((Path(__file__).parent / "clarion_qualname_parity.json").read_text("utf-8")) -@pytest.mark.parametrize( - "vec", _FIXTURE["module_normalization_vectors"], ids=lambda v: v["file_path"] -) +@pytest.mark.parametrize("vec", _FIXTURE["module_normalization_vectors"], ids=lambda v: v["file_path"]) def test_module_normalization(vec: dict[str, Any]) -> None: got = module_dotted_name(vec["file_path"]) expected = vec["expected_module"] diff --git a/tests/conformance/test_mcp_handshake.py b/tests/conformance/test_mcp_handshake.py index 26790de9..5389a629 100644 --- a/tests/conformance/test_mcp_handshake.py +++ b/tests/conformance/test_mcp_handshake.py @@ -21,21 +21,32 @@ def _drive(messages: list[dict], root: Path = FIXTURE) -> list[dict]: def test_full_client_handshake_and_every_surface() -> None: - responses = _drive([ - {"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.0"}}}, - {"jsonrpc": "2.0", "method": "notifications/initialized"}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, - {"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {}}, - {"jsonrpc": "2.0", "id": 4, "method": "prompts/list", "params": {}}, - {"jsonrpc": "2.0", "id": 5, "method": "tools/call", - "params": {"name": "scan", "arguments": {"fail_on": "ERROR"}}}, - {"jsonrpc": "2.0", "id": 6, "method": "resources/read", - "params": {"uri": "wardline://vocab"}}, - {"jsonrpc": "2.0", "id": 7, "method": "prompts/get", - "params": {"name": "wardline:loop"}}, - ]) + responses = _drive( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + }, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + {"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {}}, + {"jsonrpc": "2.0", "id": 4, "method": "prompts/list", "params": {}}, + { + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": {"name": "scan", "arguments": {"fail_on": "ERROR"}}, + }, + {"jsonrpc": "2.0", "id": 6, "method": "resources/read", "params": {"uri": "wardline://vocab"}}, + {"jsonrpc": "2.0", "id": 7, "method": "prompts/get", "params": {"name": "wardline:loop"}}, + ] + ) by_id = {r["id"]: r for r in responses} # initialize: protocolVersion echoed, serverInfo present, capabilities advertise all three init = by_id[1]["result"] @@ -44,12 +55,10 @@ def test_full_client_handshake_and_every_surface() -> None: assert {"tools", "resources", "prompts"} <= set(init["capabilities"]) # tools/list: the six documented tools, no more no less tool_names = {t["name"] for t in by_id[2]["result"]["tools"]} - assert tool_names == {"scan", "explain_taint", "judge", - "baseline_create", "baseline_update", "waiver_add"} + assert tool_names == {"scan", "explain_taint", "judge", "baseline_create", "baseline_update", "waiver_add"} # resources/list: the four stable URIs resource_uris = {r["uri"] for r in by_id[3]["result"]["resources"]} - assert resource_uris == {"wardline://vocab", "wardline://rules", - "wardline://config", "wardline://config-schema"} + assert resource_uris == {"wardline://vocab", "wardline://rules", "wardline://config", "wardline://config-schema"} # prompts/list: the one loop prompt prompt_names = {p["name"] for p in by_id[4]["result"]["prompts"]} assert prompt_names == {"wardline:loop"} @@ -72,11 +81,17 @@ def test_full_client_handshake_and_every_surface() -> None: def test_capabilities_match_actually_registered_methods() -> None: - responses = _drive([ - {"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}}, - {"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {}}, - ]) + responses = _drive( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}, + }, + {"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {}}, + ] + ) # advertising resources capability obliges resources/list to work assert "error" not in responses[1] @@ -85,25 +100,41 @@ def test_tool_execution_error_is_iserror_result_not_jsonrpc_error() -> None: # A stale/unknown fingerprint is a tool-EXECUTION error: it must come back as a # result with isError:true (so an MCP client relays the guidance to the model), # NOT as a JSON-RPC error (which clients often swallow). - responses = _drive([ - {"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/call", - "params": {"name": "explain_taint", "arguments": {"fingerprint": "0" * 64}}}, - ]) + responses = _drive( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}, + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "explain_taint", "arguments": {"fingerprint": "0" * 64}}, + }, + ] + ) by_id = {r["id"]: r for r in responses} resp = by_id[2] - assert "error" not in resp # NOT a JSON-RPC error - assert resp["result"]["isError"] is True # IS an isError result + assert "error" not in resp # NOT a JSON-RPC error + assert resp["result"]["isError"] is True # IS an isError result assert "re-scan" in resp["result"]["content"][0]["text"].lower() def test_unknown_method_is_a_jsonrpc_error() -> None: # A protocol fault (unknown method) IS a JSON-RPC error — the other half of the split. - responses = _drive([ - {"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}}, - {"jsonrpc": "2.0", "id": 2, "method": "no/such/method", "params": {}}, - ]) + responses = _drive( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}, + }, + {"jsonrpc": "2.0", "id": 2, "method": "no/such/method", "params": {}}, + ] + ) by_id = {r["id"]: r for r in responses} assert by_id[2]["error"]["code"] == -32601 diff --git a/tests/conformance/test_qualname_conformance.py b/tests/conformance/test_qualname_conformance.py index cfb3fc62..96dc3471 100644 --- a/tests/conformance/test_qualname_conformance.py +++ b/tests/conformance/test_qualname_conformance.py @@ -20,9 +20,7 @@ _CORPUS = json.loads((Path(__file__).parent / "qualnames.json").read_text("utf-8")) -@pytest.mark.parametrize( - "case", _CORPUS["module_dotted_name"], ids=lambda c: c["rel_path"] -) +@pytest.mark.parametrize("case", _CORPUS["module_dotted_name"], ids=lambda c: c["rel_path"]) def test_module_dotted_name(case: dict[str, Any]) -> None: assert module_dotted_name(case["rel_path"]) == case["expected"] diff --git a/tests/e2e/test_clarion_live.py b/tests/e2e/test_clarion_live.py index c6809f88..62b66938 100644 --- a/tests/e2e/test_clarion_live.py +++ b/tests/e2e/test_clarion_live.py @@ -41,9 +41,7 @@ def _has_wardline_routes(binary: str) -> bool: release on PATH predates these routes (it 404s them before auth); the 1.0.1 build does mount them. Discriminate by the route string baked into the binary.""" try: - out = subprocess.run( - ["strings", binary], capture_output=True, text=True, timeout=30 - ).stdout + out = subprocess.run(["strings", binary], capture_output=True, text=True, timeout=30).stdout except (OSError, subprocess.SubprocessError): return False return "/api/wardline/taint-facts" in out @@ -119,14 +117,18 @@ def clarion_server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> object: # entities to .clarion/clarion.db. Both take a positional/--path of the project. install = subprocess.run( [clarion_bin, "install", "--path", str(proj)], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ) if install.returncode != 0: pytest.skip(f"clarion install failed: {install.stderr.strip() or install.stdout.strip()}") analyze = subprocess.run( [clarion_bin, "analyze", str(proj)], - capture_output=True, text=True, timeout=120, + capture_output=True, + text=True, + timeout=120, ) out = f"{analyze.stdout}\n{analyze.stderr}" if analyze.returncode != 0: diff --git a/tests/e2e/test_judge_live.py b/tests/e2e/test_judge_live.py index e18a0456..3f705c0a 100644 --- a/tests/e2e/test_judge_live.py +++ b/tests/e2e/test_judge_live.py @@ -13,8 +13,13 @@ def test_live_triage_round_trip() -> None: """One real OpenRouter call returns a schema-valid verdict; a second call hits cache.""" req = JudgeRequest( - rule_id="PY-WL-101", message="untrusted reaches trusted", severity="ERROR", - file_path="svc/v.py", line=4, qualname="svc.v.validate", fingerprint="a" * 64, + rule_id="PY-WL-101", + message="untrusted reaches trusted", + severity="ERROR", + file_path="svc/v.py", + line=4, + qualname="svc.v.validate", + fingerprint="a" * 64, taint_summary="declared_return=GUARDED, actual_return=MIXED_RAW", surrounding_code=( "1: @trust_boundary(to_level=TaintState.GUARDED)\n" diff --git a/tests/unit/clarion/test_client.py b/tests/unit/clarion/test_client.py index 2d406e73..7d7e4228 100644 --- a/tests/unit/clarion/test_client.py +++ b/tests/unit/clarion/test_client.py @@ -23,8 +23,11 @@ def request(self, method, url, body, headers): def _client(transport, **kw): return ClarionClient( - "http://clarion.example", secret="s3cr3t", project="proj", - transport=transport, **kw, + "http://clarion.example", + secret="s3cr3t", + project="proj", + transport=transport, + **kw, ) @@ -58,8 +61,7 @@ def test_write_chunks_against_batch_max(): def test_batch_get_chunks_and_preserves_input_order(): r1 = json.dumps([{"qualname": "a", "exists": False}, {"qualname": "b", "exists": False}]) - r2 = json.dumps([{"qualname": "c", "exists": True, "wardline_json": {"x": 1}, - "current_content_hash": "deadbeef"}]) + r2 = json.dumps([{"qualname": "c", "exists": True, "wardline_json": {"x": 1}, "current_content_hash": "deadbeef"}]) t = FakeTransport([Response(status=200, body=r1), Response(status=200, body=r2)]) views = _client(t, batch_max=2).batch_get(["a", "b", "c"]) assert [v.qualname for v in views] == ["a", "b", "c"] diff --git a/tests/unit/clarion/test_config.py b/tests/unit/clarion/test_config.py index 6da2ecf8..6571498f 100644 --- a/tests/unit/clarion/test_config.py +++ b/tests/unit/clarion/test_config.py @@ -14,17 +14,13 @@ def test_env_var_wins_over_dotenv(tmp_path, monkeypatch): def test_dotenv_used_when_env_unset(tmp_path, monkeypatch): monkeypatch.delenv(WARDLINE_CLARION_TOKEN_ENV, raising=False) - (tmp_path / ".env").write_text( - f'{WARDLINE_CLARION_TOKEN_ENV}="quoted-secret"\n', encoding="utf-8" - ) + (tmp_path / ".env").write_text(f'{WARDLINE_CLARION_TOKEN_ENV}="quoted-secret"\n', encoding="utf-8") assert load_clarion_token(tmp_path) == "quoted-secret" def test_dotenv_single_quoted_value_is_unquoted(tmp_path, monkeypatch): monkeypatch.delenv(WARDLINE_CLARION_TOKEN_ENV, raising=False) - (tmp_path / ".env").write_text( - f"{WARDLINE_CLARION_TOKEN_ENV}='single-secret'\n", encoding="utf-8" - ) + (tmp_path / ".env").write_text(f"{WARDLINE_CLARION_TOKEN_ENV}='single-secret'\n", encoding="utf-8") assert load_clarion_token(tmp_path) == "single-secret" diff --git a/tests/unit/clarion/test_facts.py b/tests/unit/clarion/test_facts.py index 92bcabf4..53830433 100644 --- a/tests/unit/clarion/test_facts.py +++ b/tests/unit/clarion/test_facts.py @@ -41,6 +41,7 @@ def test_leaky_fact_carries_the_taint_projection(tmp_path): def test_content_hash_is_blake3_whole_file_and_top_level_and_in_blob(tmp_path): proj, result = _scan_leaky(tmp_path) import blake3 + expected = blake3.blake3((proj / "svc.py").read_bytes()).hexdigest() fact = next(f for f in build_taint_facts(result, proj) if f["qualname"] == "svc.leaky") assert fact["content_hash_at_compute"] == expected @@ -51,6 +52,7 @@ def test_content_hash_is_blake3_whole_file_and_top_level_and_in_blob(tmp_path): def test_per_file_hash_is_memoized(tmp_path, monkeypatch): proj, result = _scan_leaky(tmp_path) import wardline.clarion.facts as facts_mod + calls = {"n": 0} real = facts_mod._read_bytes diff --git a/tests/unit/clarion/test_hmac.py b/tests/unit/clarion/test_hmac.py index f01a2ab1..ccc35a07 100644 --- a/tests/unit/clarion/test_hmac.py +++ b/tests/unit/clarion/test_hmac.py @@ -15,9 +15,7 @@ def test_canonical_message_is_three_lines_no_trailing_newline(): def test_empty_body_hashes_the_empty_string(): # bodyless GET: sha256(b"") = e3b0c4…b855 msg = canonical_message("GET", "/api/wardline/taint-facts?qualname=x", b"") - assert msg.endswith( - "\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ) + assert msg.endswith("\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") def test_sign_request_matches_a_reference_hmac(): diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 17549519..201a8267 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -51,8 +51,7 @@ def test_scan_format_sarif_still_gates(tmp_path: Path) -> None: proj.mkdir() _write(proj, "svc.py", _LEAKY) # PY-WL-101 ERROR defect result = CliRunner().invoke( - cli, ["scan", str(proj), "--format", "sarif", "--output", str(tmp_path / "o.sarif"), - "--fail-on", "ERROR"] + cli, ["scan", str(proj), "--format", "sarif", "--output", str(tmp_path / "o.sarif"), "--fail-on", "ERROR"] ) assert result.exit_code == 1, result.output @@ -75,9 +74,7 @@ def test_judge_is_registered() -> None: def test_scan_fail_on_clean_fixture_exits_zero(tmp_path: Path) -> None: # The sample fixture has no active CRITICAL defect, so --fail-on CRITICAL is clean. out = tmp_path / "findings.jsonl" - result = CliRunner().invoke( - cli, ["scan", str(FIXTURE), "--output", str(out), "--fail-on", "CRITICAL"] - ) + result = CliRunner().invoke(cli, ["scan", str(FIXTURE), "--output", str(out), "--fail-on", "CRITICAL"]) assert result.exit_code == 0, result.output @@ -233,9 +230,7 @@ def test_scan_fail_on_unanalyzed_exits_one(tmp_path) -> None: proj = tmp_path / "proj" proj.mkdir() _write(proj, "bad.py", _UNPARSEABLE) - res = CliRunner().invoke( - scan, [str(proj), "--output", str(tmp_path / "f.jsonl"), "--fail-on-unanalyzed"] - ) + res = CliRunner().invoke(scan, [str(proj), "--output", str(tmp_path / "f.jsonl"), "--fail-on-unanalyzed"]) assert res.exit_code == 1, res.output @@ -368,7 +363,7 @@ def test_baseline_create_excludes_active_waivers(tmp_path) -> None: doc = _yaml.safe_load((proj / ".wardline" / "baseline.yaml").read_text()) or {} fps = {e["fingerprint"] for e in (doc.get("entries") or [])} assert fp_waived not in fps # active-waiver fingerprint excluded - assert fp_kept in fps # non-waived defect still baselined + assert fp_kept in fps # non-waived defect still baselined def test_scan_relative_root_emits_relative_path_and_qualname(tmp_path) -> None: @@ -385,7 +380,7 @@ def test_scan_relative_root_emits_relative_path_and_qualname(tmp_path) -> None: findings = [_json.loads(ln) for ln in Path("proj/f.jsonl").read_text().splitlines() if ln.strip()] leak = next(f for f in findings if f["rule_id"] == "PY-WL-101") assert leak["location"]["path"] == "svc.py" # relative, not /abs/.../svc.py - assert leak["qualname"] == "svc.leaky" # clean module prefix, not '.tmp....svc.leaky' + assert leak["qualname"] == "svc.leaky" # clean module prefix, not '.tmp....svc.leaky' def test_scan_filigree_emit_success(tmp_path, monkeypatch) -> None: @@ -473,9 +468,7 @@ def test_scan_clarion_write_success(tmp_path, monkeypatch) -> None: lambda *a, **k: WriteResult(reachable=True, written=2), ) out = tmp_path / "f.jsonl" - result = CliRunner().invoke( - scan, [str(proj), "--output", str(out), "--clarion-url", "http://x/api/taint"] - ) + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--clarion-url", "http://x/api/taint"]) assert result.exit_code == 0, result.output assert "wrote 2 taint fact(s) to http://x/api/taint" in result.output @@ -492,9 +485,7 @@ def test_scan_clarion_soft_outage_does_not_change_exit(tmp_path, monkeypatch) -> ) out = tmp_path / "f.jsonl" # No --fail-on: a normal scan of _LEAKY exits 0. A soft outage must NOT bump it to 2. - result = CliRunner().invoke( - scan, [str(proj), "--output", str(out), "--clarion-url", "http://x/api/taint"] - ) + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--clarion-url", "http://x/api/taint"]) assert result.exit_code == 0, result.output assert "Clarion taint store not written" in result.output assert "scan unaffected" in result.output @@ -512,9 +503,7 @@ def _raise(*a, **k): monkeypatch.setattr("wardline.clarion.write.write_facts_to_clarion", _raise) out = tmp_path / "f.jsonl" - result = CliRunner().invoke( - scan, [str(proj), "--output", str(out), "--clarion-url", "http://x/api/taint"] - ) + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--clarion-url", "http://x/api/taint"]) assert result.exit_code == 2, result.output assert "bad request" in result.output @@ -530,7 +519,8 @@ def test_baseline_create_honors_custom_config_waivers(tmp_path) -> None: runner.invoke(scan, [str(proj), "--output", str(out)]) fp = next( _json.loads(ln)["fingerprint"] - for ln in out.read_text().splitlines() if ln.strip() and _json.loads(ln)["rule_id"] == "PY-WL-101" + for ln in out.read_text().splitlines() + if ln.strip() and _json.loads(ln)["rule_id"] == "PY-WL-101" ) custom = tmp_path / "custom.yaml" # NOT proj/wardline.yaml custom.write_text("waivers:\n - fingerprint: " + fp + "\n reason: handled\n", encoding="utf-8") @@ -563,10 +553,17 @@ def _fake_fp_response(): # type: ignore[no-untyped-def] from datetime import UTC, datetime from wardline.core.judge import JudgeResponse, JudgeVerdict + return JudgeResponse( - verdict=JudgeVerdict.FALSE_POSITIVE, rationale="over-taint", confidence=0.9, - model_id="m", recorded_at=datetime.now(UTC), prompt_tokens_total=1, - prompt_tokens_cached=None, policy_hash="sha256:x") + verdict=JudgeVerdict.FALSE_POSITIVE, + rationale="over-taint", + confidence=0.9, + model_id="m", + recorded_at=datetime.now(UTC), + prompt_tokens_total=1, + prompt_tokens_cached=None, + policy_hash="sha256:x", + ) def test_judge_dry_run_reports_without_writing(monkeypatch, tmp_path) -> None: @@ -574,6 +571,7 @@ def test_judge_dry_run_reports_without_writing(monkeypatch, tmp_path) -> None: import wardline.cli.judge as judge_cli from wardline.cli.main import cli + proj = _make_judge_proj(tmp_path) monkeypatch.setattr(judge_cli, "call_judge", lambda req, **kw: _fake_fp_response()) monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "k") @@ -592,6 +590,7 @@ def test_judge_write_persists_false_positives(monkeypatch, tmp_path) -> None: import wardline.cli.judge as judge_cli from wardline.cli.main import cli from wardline.core.judged import load_judged + proj = _make_judge_proj(tmp_path) monkeypatch.setattr(judge_cli, "call_judge", lambda req, **kw: _fake_fp_response()) monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "k") @@ -604,6 +603,7 @@ def test_judge_missing_key_exits_2(monkeypatch, tmp_path) -> None: from click.testing import CliRunner from wardline.cli.main import cli + proj = _make_judge_proj(tmp_path) monkeypatch.delenv("WARDLINE_OPENROUTER_API_KEY", raising=False) result = CliRunner().invoke(cli, ["judge", str(proj)]) @@ -614,6 +614,7 @@ def test_judge_reads_key_from_dotenv(monkeypatch, tmp_path) -> None: import os from wardline.cli.judge import _load_env_key + (tmp_path / ".env").write_text("WARDLINE_OPENROUTER_API_KEY=sk-or-fromdotenv\n") monkeypatch.delenv("WARDLINE_OPENROUTER_API_KEY", raising=False) _load_env_key(tmp_path) @@ -624,6 +625,7 @@ def test_dotenv_does_not_override_existing_env(monkeypatch, tmp_path) -> None: import os from wardline.cli.judge import _load_env_key + (tmp_path / ".env").write_text("WARDLINE_OPENROUTER_API_KEY=sk-or-fromdotenv\n") monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "sk-or-fromenv") _load_env_key(tmp_path) @@ -634,15 +636,23 @@ def _fake_fp_response_conf(conf): # type: ignore[no-untyped-def] from datetime import UTC, datetime from wardline.core.judge import JudgeResponse, JudgeVerdict + return JudgeResponse( - verdict=JudgeVerdict.FALSE_POSITIVE, rationale="over-taint", confidence=conf, - model_id="m", recorded_at=datetime.now(UTC), prompt_tokens_total=1, - prompt_tokens_cached=None, policy_hash="sha256:x") + verdict=JudgeVerdict.FALSE_POSITIVE, + rationale="over-taint", + confidence=conf, + model_id="m", + recorded_at=datetime.now(UTC), + prompt_tokens_total=1, + prompt_tokens_cached=None, + policy_hash="sha256:x", + ) def test_judge_low_confidence_fp_held_back_from_write(monkeypatch, tmp_path) -> None: import wardline.cli.judge as judge_cli from wardline.cli.main import cli + proj = _make_judge_proj(tmp_path) monkeypatch.setattr(judge_cli, "call_judge", lambda req, **kw: _fake_fp_response_conf(0.3)) monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "k") @@ -658,6 +668,7 @@ def test_judge_write_then_scan_gate_is_cleared(monkeypatch, tmp_path) -> None: # `judge --write` must suppress the finding for `scan --fail-on` too. import wardline.cli.judge as judge_cli from wardline.cli.main import cli + proj = _make_judge_proj(tmp_path) out = tmp_path / "f.jsonl" # 1) before judging, the active defect trips the gate diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py index dbed0b5e..278ab29f 100644 --- a/tests/unit/cli/test_install.py +++ b/tests/unit/cli/test_install.py @@ -6,9 +6,7 @@ def test_scan_reads_filigree_url_from_config(tmp_path: Path, monkeypatch) -> None: - (tmp_path / "wardline.yaml").write_text( - 'filigree:\n url: "http://configured-filigree"\n', encoding="utf-8" - ) + (tmp_path / "wardline.yaml").write_text('filigree:\n url: "http://configured-filigree"\n', encoding="utf-8") (tmp_path / "m.py").write_text("x = 1\n", encoding="utf-8") captured: dict[str, object] = {} @@ -28,9 +26,7 @@ def emit(self, findings): # noqa: ANN001 def test_mcp_resolves_clarion_url_from_config(tmp_path: Path, monkeypatch) -> None: - (tmp_path / "wardline.yaml").write_text( - 'clarion:\n url: "http://configured-clarion"\n', encoding="utf-8" - ) + (tmp_path / "wardline.yaml").write_text('clarion:\n url: "http://configured-clarion"\n', encoding="utf-8") captured: dict[str, object] = {} class _FakeServer: @@ -76,8 +72,7 @@ def test_install_opt_outs(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) result = CliRunner().invoke( cli, - ["install", "--root", str(tmp_path), "--no-agents-md", "--no-skill", - "--no-mcp", "--no-bindings"], + ["install", "--root", str(tmp_path), "--no-agents-md", "--no-skill", "--no-mcp", "--no-bindings"], ) assert result.exit_code == 0, result.output assert (tmp_path / "CLAUDE.md").is_file() @@ -90,9 +85,7 @@ def test_install_no_claude_md_still_writes_agents(tmp_path: Path, monkeypatch) - monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) - result = CliRunner().invoke( - cli, ["install", "--root", str(tmp_path), "--no-claude-md"] - ) + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path), "--no-claude-md"]) assert result.exit_code == 0, result.output assert not (tmp_path / "CLAUDE.md").exists() assert (tmp_path / "AGENTS.md").is_file() diff --git a/tests/unit/cli/test_mcp_cli.py b/tests/unit/cli/test_mcp_cli.py index 6f97145a..382449dc 100644 --- a/tests/unit/cli/test_mcp_cli.py +++ b/tests/unit/cli/test_mcp_cli.py @@ -7,10 +7,19 @@ def test_stdio_loop_handles_initialize_then_tools_list(tmp_path) -> None: server = WardlineMCPServer(root=tmp_path) stdin = io.StringIO( - json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2024-11-05", "capabilities": {}}}) + "\n" - + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n" - + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n" + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}}, + } + ) + + "\n" + + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + "\n" + + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + + "\n" ) stdout = io.StringIO() server.rpc.run_stdio(stdin=stdin, stdout=stdout) @@ -40,9 +49,17 @@ def test_mcp_command_runs_stdio_end_to_end(tmp_path) -> None: from wardline.cli.main import cli stdin = ( - json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2024-11-05", "capabilities": {}}}) + "\n" - + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n" + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}}, + } + ) + + "\n" + + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + + "\n" ) result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)], input=stdin) assert result.exit_code == 0, result.output diff --git a/tests/unit/core/test_baseline.py b/tests/unit/core/test_baseline.py index c8a842d6..b1209c13 100644 --- a/tests/unit/core/test_baseline.py +++ b/tests/unit/core/test_baseline.py @@ -20,8 +20,12 @@ def _finding(fp: str, *, rule: str = "PY-WL-101", sev: Severity = Severity.ERROR, path: str = "src/m.py") -> Finding: return Finding( - rule_id=rule, message=f"msg {fp[:4]}", severity=sev, kind=Kind.DEFECT, - location=Location(path=path, line_start=1), fingerprint=fp, + rule_id=rule, + message=f"msg {fp[:4]}", + severity=sev, + kind=Kind.DEFECT, + location=Location(path=path, line_start=1), + fingerprint=fp, ) diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 5276f25e..f893753b 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -65,6 +65,7 @@ def test_waivers_key_does_not_warn(recwarn, tmp_path) -> None: def test_judge_settings_defaults() -> None: from wardline.core.config import parse_judge_settings + s = parse_judge_settings({}) assert s.model == "anthropic/claude-opus-4-8" assert s.context_lines == 30 @@ -74,8 +75,10 @@ def test_judge_settings_defaults() -> None: def test_judge_settings_from_mapping() -> None: from wardline.core.config import parse_judge_settings - s = parse_judge_settings({"model": "anthropic/claude-sonnet-4-6", "context_lines": 10, - "max_findings": 50, "policy_file": "POLICY.md"}) + + s = parse_judge_settings( + {"model": "anthropic/claude-sonnet-4-6", "context_lines": 10, "max_findings": 50, "policy_file": "POLICY.md"} + ) assert s.model == "anthropic/claude-sonnet-4-6" and s.context_lines == 10 assert s.max_findings == 50 and s.policy_file == "POLICY.md" @@ -85,6 +88,7 @@ def test_judge_settings_bad_type_raises() -> None: from wardline.core.config import parse_judge_settings from wardline.core.errors import ConfigError + with pytest.raises(ConfigError): parse_judge_settings({"context_lines": "lots"}) @@ -94,12 +98,14 @@ def test_judge_settings_rejects_nonpositive_max_findings() -> None: from wardline.core.config import parse_judge_settings from wardline.core.errors import ConfigError + with pytest.raises(ConfigError): parse_judge_settings({"max_findings": 0}) def test_judge_settings_write_confidence_floor() -> None: from wardline.core.config import parse_judge_settings + assert parse_judge_settings({}).write_confidence_floor == 0.5 assert parse_judge_settings({"write_confidence_floor": 0.0}).write_confidence_floor == 0.0 @@ -109,6 +115,7 @@ def test_judge_settings_rejects_out_of_range_floor() -> None: from wardline.core.config import parse_judge_settings from wardline.core.errors import ConfigError + with pytest.raises(ConfigError): parse_judge_settings({"write_confidence_floor": 1.5}) @@ -137,8 +144,10 @@ def test_full_valid_config_passes(tmp_path) -> None: cfg = load(p) assert cfg.source_roots == ("src",) assert cfg.judge == { - "model": "anthropic/claude-opus-4-8", "context_lines": 10, - "max_findings": 50, "write_confidence_floor": 0.7, + "model": "anthropic/claude-opus-4-8", + "context_lines": 10, + "max_findings": 50, + "write_confidence_floor": 0.7, } @@ -191,17 +200,13 @@ def test_urls_default_to_none() -> None: def test_unknown_clarion_key_is_rejected(tmp_path: Path) -> None: - (tmp_path / "wardline.yaml").write_text( - "clarion:\n bogus: 1\n", encoding="utf-8" - ) + (tmp_path / "wardline.yaml").write_text("clarion:\n bogus: 1\n", encoding="utf-8") with pytest.raises(ConfigError): load(tmp_path / "wardline.yaml") def test_resolve_precedence_flag_beats_env_beats_config(tmp_path: Path, monkeypatch) -> None: - (tmp_path / "wardline.yaml").write_text( - 'clarion:\n url: "http://from-config"\n', encoding="utf-8" - ) + (tmp_path / "wardline.yaml").write_text('clarion:\n url: "http://from-config"\n', encoding="utf-8") monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) assert resolve_clarion_url(None, tmp_path, None) == "http://from-config" monkeypatch.setenv("WARDLINE_CLARION_URL", "http://from-env") diff --git a/tests/unit/core/test_descriptor.py b/tests/unit/core/test_descriptor.py index 5fe68c64..6321aa42 100644 --- a/tests/unit/core/test_descriptor.py +++ b/tests/unit/core/test_descriptor.py @@ -13,12 +13,9 @@ def test_descriptor_carries_registry_version() -> None: def test_descriptor_round_trips_registry() -> None: descriptor = build_vocabulary_descriptor() - reconstructed = { - e["canonical_name"]: (e["group"], e["attrs"]) for e in descriptor["entries"] - } + reconstructed = {e["canonical_name"]: (e["group"], e["attrs"]) for e in descriptor["entries"]} expected = { - name: (entry.group, {k: v.__name__ for k, v in entry.attrs.items()}) - for name, entry in REGISTRY.items() + name: (entry.group, {k: v.__name__ for k, v in entry.attrs.items()}) for name, entry in REGISTRY.items() } assert reconstructed == expected @@ -61,10 +58,7 @@ def test_committed_vocabulary_yaml_matches_registry() -> None: from importlib.resources import files file_text = files("wardline.core").joinpath("vocabulary.yaml").read_text(encoding="utf-8") - regen_hint = ( - "vocabulary.yaml is stale; regenerate: " - ".venv/bin/wardline vocab > src/wardline/core/vocabulary.yaml" - ) + regen_hint = "vocabulary.yaml is stale; regenerate: .venv/bin/wardline vocab > src/wardline/core/vocabulary.yaml" # Content currency (catches a REGISTRY change the file didn't track)... assert yaml.safe_load(file_text) == build_vocabulary_descriptor(), regen_hint # ...and byte-identity (catches a reformat / emitter drift the parse misses). diff --git a/tests/unit/core/test_explain.py b/tests/unit/core/test_explain.py index 52a986ea..0e8b7b24 100644 --- a/tests/unit/core/test_explain.py +++ b/tests/unit/core/test_explain.py @@ -39,11 +39,7 @@ def _leaky_var_project(tmp_path: Path) -> Path: def _first_active_taint_finding(root: Path): result = run_scan(root) for f in result.findings: - if ( - f.kind is Kind.DEFECT - and f.suppressed is SuppressionState.ACTIVE - and "actual_return" in f.properties - ): + if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE and "actual_return" in f.properties: return f raise AssertionError("leaky project has no active untrusted-reaches-trusted defect") diff --git a/tests/unit/core/test_explain_chain.py b/tests/unit/core/test_explain_chain.py index 62b0eae4..a71eb46d 100644 --- a/tests/unit/core/test_explain_chain.py +++ b/tests/unit/core/test_explain_chain.py @@ -22,15 +22,20 @@ def _proj(tmp_path): def _fresh_view(proj, qualname, callee_qualname): h = blake3.blake3((proj / "svc.py").read_bytes()).hexdigest() blob = { - "schema_version": "wardline-taint-1", "qualname": qualname, + "schema_version": "wardline-taint-1", + "qualname": qualname, "content_hash_at_compute": h, - "taint": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW", - "source": "anchored", "contributing_callee_qualname": callee_qualname, - "resolved_call_count": 1, "unresolved_call_count": 0}, + "taint": { + "declared_return": "INTEGRAL", + "actual_return": "EXTERNAL_RAW", + "source": "anchored", + "contributing_callee_qualname": callee_qualname, + "resolved_call_count": 1, + "unresolved_call_count": 0, + }, "findings": [], } - return TaintFactView(qualname=qualname, exists=True, wardline_json=blob, - current_content_hash=h) + return TaintFactView(qualname=qualname, exists=True, wardline_json=blob, current_content_hash=h) class MapClient: @@ -43,11 +48,13 @@ def batch_get(self, qualnames): def test_chain_walks_to_the_boundary(tmp_path): proj = _proj(tmp_path) - client = MapClient({ - "svc.leaky": _fresh_view(proj, "svc.leaky", "svc.mid"), - "svc.mid": _fresh_view(proj, "svc.mid", "svc.read_raw"), - "svc.read_raw": _fresh_view(proj, "svc.read_raw", None), # boundary leaf - }) + client = MapClient( + { + "svc.leaky": _fresh_view(proj, "svc.leaky", "svc.mid"), + "svc.mid": _fresh_view(proj, "svc.mid", "svc.read_raw"), + "svc.read_raw": _fresh_view(proj, "svc.read_raw", None), # boundary leaf + } + ) chain = explain_chain(proj, sink_qualname="svc.leaky", clarion=client, max_hops=10) assert [hop.qualname for hop in chain.hops] == ["svc.leaky", "svc.mid", "svc.read_raw"] assert chain.truncated_at is None # reached the leaf cleanly @@ -57,10 +64,12 @@ def test_chain_truncates_explicitly_on_stale_hop(tmp_path): proj = _proj(tmp_path) stale = _fresh_view(proj, "svc.mid", "svc.read_raw") stale.wardline_json["content_hash_at_compute"] = "0" * 64 # stamp != live hash - client = MapClient({ - "svc.leaky": _fresh_view(proj, "svc.leaky", "svc.mid"), - "svc.mid": stale, - }) + client = MapClient( + { + "svc.leaky": _fresh_view(proj, "svc.leaky", "svc.mid"), + "svc.mid": stale, + } + ) chain = explain_chain(proj, sink_qualname="svc.leaky", clarion=client, max_hops=10) assert [hop.qualname for hop in chain.hops] == ["svc.leaky"] assert chain.truncated_at == "svc.mid" # explicit, never a silent stop @@ -68,11 +77,13 @@ def test_chain_truncates_explicitly_on_stale_hop(tmp_path): def test_chain_respects_max_hops(tmp_path): proj = _proj(tmp_path) - client = MapClient({ - "svc.leaky": _fresh_view(proj, "svc.leaky", "svc.mid"), - "svc.mid": _fresh_view(proj, "svc.mid", "svc.read_raw"), - "svc.read_raw": _fresh_view(proj, "svc.read_raw", None), - }) + client = MapClient( + { + "svc.leaky": _fresh_view(proj, "svc.leaky", "svc.mid"), + "svc.mid": _fresh_view(proj, "svc.mid", "svc.read_raw"), + "svc.read_raw": _fresh_view(proj, "svc.read_raw", None), + } + ) chain = explain_chain(proj, sink_qualname="svc.leaky", clarion=client, max_hops=2) assert len(chain.hops) == 2 assert chain.truncated_at == "svc.read_raw" # the unwalked next hop diff --git a/tests/unit/core/test_explain_clarion.py b/tests/unit/core/test_explain_clarion.py index 9455fdd5..c275daaf 100644 --- a/tests/unit/core/test_explain_clarion.py +++ b/tests/unit/core/test_explain_clarion.py @@ -35,9 +35,14 @@ def _fresh_blob(proj, qualname): "schema_version": "wardline-taint-1", "qualname": qualname, "content_hash_at_compute": h, - "taint": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW", - "source": "anchored", "contributing_callee_qualname": "svc.read_raw", - "resolved_call_count": 1, "unresolved_call_count": 0}, + "taint": { + "declared_return": "INTEGRAL", + "actual_return": "EXTERNAL_RAW", + "source": "anchored", + "contributing_callee_qualname": "svc.read_raw", + "resolved_call_count": 1, + "unresolved_call_count": 0, + }, "findings": [{"rule_id": "PY-WL-101", "fingerprint": "fp-leaky-1", "line_start": 6}], }, h @@ -45,11 +50,11 @@ def _fresh_blob(proj, qualname): def test_fresh_fact_is_served_without_reanalysis(tmp_path, monkeypatch): proj = _proj(tmp_path) blob, h = _fresh_blob(proj, "svc.leaky") - view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, - current_content_hash=h) + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, current_content_hash=h) client = SpyClient([view]) import wardline.core.explain as explain_mod + calls = {"n": 0} real = explain_mod.run_scan @@ -66,7 +71,7 @@ def counting(*a, **k): assert exp.immediate_tainted_callee == "read_raw" assert exp.path == "svc.py" assert exp.rule_id == "PY-WL-101" - assert exp.fingerprint == "fp-leaky-1" # served from the blob (no fingerprint passed by caller) + assert exp.fingerprint == "fp-leaky-1" # served from the blob (no fingerprint passed by caller) assert calls["n"] == 0 assert client.batch_get_calls == 1 @@ -75,10 +80,8 @@ def test_stale_hash_falls_back_to_reanalysis(tmp_path): proj = _proj(tmp_path) blob, h = _fresh_blob(proj, "svc.leaky") blob["content_hash_at_compute"] = "0" * 64 - view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, - current_content_hash=h) - exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), - sink_qualname="svc.leaky") + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, current_content_hash=h) + exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), sink_qualname="svc.leaky") assert exp is not None assert exp.sink_qualname == "svc.leaky" @@ -86,18 +89,15 @@ def test_stale_hash_falls_back_to_reanalysis(tmp_path): def test_missing_current_hash_is_stale(tmp_path): proj = _proj(tmp_path) blob, h = _fresh_blob(proj, "svc.leaky") - view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, - current_content_hash=None) - exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), - sink_qualname="svc.leaky") + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, current_content_hash=None) + exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), sink_qualname="svc.leaky") assert exp is not None def test_exists_false_falls_back(tmp_path): proj = _proj(tmp_path) view = TaintFactView(qualname="svc.leaky", exists=False) - exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), - sink_qualname="svc.leaky") + exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), sink_qualname="svc.leaky") assert exp is not None @@ -112,15 +112,18 @@ def test_malformed_blob_falls_back_to_reanalysis(tmp_path): proj = _proj(tmp_path) h = blake3.blake3((proj / "svc.py").read_bytes()).hexdigest() # fresh hash but a structurally broken blob (taint is not a dict, findings not a list) - bad = {"schema_version": "wardline-taint-1", "qualname": "svc.leaky", - "content_hash_at_compute": h, "taint": "oops", "findings": "nope"} - view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=bad, - current_content_hash=h) - exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), - sink_qualname="svc.leaky") + bad = { + "schema_version": "wardline-taint-1", + "qualname": "svc.leaky", + "content_hash_at_compute": h, + "taint": "oops", + "findings": "nope", + } + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=bad, current_content_hash=h) + exp = explain_finding(proj, path="svc.py", line=6, clarion=SpyClient([view]), sink_qualname="svc.leaky") assert exp is not None assert exp.sink_qualname == "svc.leaky" # came from the real re-scan, not the bad blob - assert exp.tier_in == "EXTERNAL_RAW" # only the real re-scan produces this + assert exp.tier_in == "EXTERNAL_RAW" # only the real re-scan produces this def test_raising_client_falls_back_to_reanalysis(tmp_path): @@ -135,8 +138,7 @@ class RaisingClient: def batch_get(self, qualnames): raise ClarionError("Clarion rejected batch-get (401; code=PERMISSION)") - exp = explain_finding(proj, path="svc.py", line=6, clarion=RaisingClient(), - sink_qualname="svc.leaky") + exp = explain_finding(proj, path="svc.py", line=6, clarion=RaisingClient(), sink_qualname="svc.leaky") assert exp is not None assert exp.sink_qualname == "svc.leaky" # from the real re-scan, not raised - assert exp.tier_in == "EXTERNAL_RAW" # only the real re-scan produces this + assert exp.tier_in == "EXTERNAL_RAW" # only the real re-scan produces this diff --git a/tests/unit/core/test_filigree_emit.py b/tests/unit/core/test_filigree_emit.py index cd2a7c4d..8ba0da05 100644 --- a/tests/unit/core/test_filigree_emit.py +++ b/tests/unit/core/test_filigree_emit.py @@ -16,7 +16,10 @@ def _f(**kw: object) -> Finding: base: dict[str, object] = dict( - rule_id="PY-WL-101", message="m", severity=Severity.ERROR, kind=Kind.DEFECT, + rule_id="PY-WL-101", + message="m", + severity=Severity.ERROR, + kind=Kind.DEFECT, location=Location(path="src/m.py", line_start=5, line_end=6), fingerprint="a" * 64, ) @@ -48,9 +51,7 @@ def test_fingerprint_is_top_level_and_severity_lowercased() -> None: def test_metadata_namespaced_and_carries_suppression() -> None: - wire = build_scan_results_body([ - _f(suppressed=SuppressionState.WAIVED, suppression_reason="fp") - ])["findings"][0] + wire = build_scan_results_body([_f(suppressed=SuppressionState.WAIVED, suppression_reason="fp")])["findings"][0] assert set(wire["metadata"]) == {"wardline"} assert wire["metadata"]["wardline"]["suppressed"] == "waived" assert wire["metadata"]["wardline"]["suppression_reason"] == "fp" @@ -67,10 +68,7 @@ def test_suggestion_capped_at_10k_and_omitted_when_none() -> None: def test_all_kinds_emitted() -> None: - findings = [ - _f(kind=k, severity=Severity.NONE if k is not Kind.DEFECT else Severity.ERROR) - for k in Kind - ] + findings = [_f(kind=k, severity=Severity.NONE if k is not Kind.DEFECT else Severity.ERROR) for k in Kind] body = build_scan_results_body(findings) assert len(body["findings"]) == len(list(Kind)) fact = next(w for w, f in zip(body["findings"], findings, strict=True) if f.kind is Kind.FACT) @@ -94,11 +92,14 @@ def post(self, url: str, body: bytes, headers: dict[str, str]) -> Response: def _ok_body() -> str: - return json.dumps({ - "succeeded": ["id1"], "failed": [], - "stats": {"findings_created": 1, "findings_updated": 0}, - "warnings": ["severity coerced"], - }) + return json.dumps( + { + "succeeded": ["id1"], + "failed": [], + "stats": {"findings_created": 1, "findings_updated": 0}, + "warnings": ["severity coerced"], + } + ) def test_success_surfaces_stats_and_warnings() -> None: @@ -167,8 +168,11 @@ def test_urllib_transport_converts_httperror_to_response(monkeypatch) -> None: def _raise_http_error(req, timeout=None): # noqa: ARG001 raise urllib.error.HTTPError( - url="http://x/api/loom/scan-results", code=400, msg="Bad Request", - hdrs=None, fp=io.BytesIO(b'{"error":"path required"}'), + url="http://x/api/loom/scan-results", + code=400, + msg="Bad Request", + hdrs=None, + fp=io.BytesIO(b'{"error":"path required"}'), ) monkeypatch.setattr(urllib.request, "urlopen", _raise_http_error) @@ -185,8 +189,8 @@ def test_urllib_transport_rejects_non_http_scheme() -> None: def test_judged_finding_carries_suppression_metadata() -> None: - wire = build_scan_results_body([ - _f(suppressed=SuppressionState.JUDGED, suppression_reason="over-taint floor") - ])["findings"][0] + wire = build_scan_results_body([_f(suppressed=SuppressionState.JUDGED, suppression_reason="over-taint floor")])[ + "findings" + ][0] assert wire["metadata"]["wardline"]["suppressed"] == "judged" assert wire["metadata"]["wardline"]["suppression_reason"] == "over-taint floor" diff --git a/tests/unit/core/test_finding.py b/tests/unit/core/test_finding.py index b346c434..7c1c8ee4 100644 --- a/tests/unit/core/test_finding.py +++ b/tests/unit/core/test_finding.py @@ -113,8 +113,6 @@ def test_filigree_metadata_includes_suppression_only_when_suppressed() -> None: active = to_filigree_metadata(_finding())["wardline"] assert "suppressed" not in active - waived = to_filigree_metadata( - _finding(suppressed=SuppressionState.WAIVED, suppression_reason="ok") - )["wardline"] + waived = to_filigree_metadata(_finding(suppressed=SuppressionState.WAIVED, suppression_reason="ok"))["wardline"] assert waived["suppressed"] == "waived" assert waived["suppression_reason"] == "ok" diff --git a/tests/unit/core/test_judge.py b/tests/unit/core/test_judge.py index 08e88d0a..e9463044 100644 --- a/tests/unit/core/test_judge.py +++ b/tests/unit/core/test_judge.py @@ -28,9 +28,15 @@ def _req(**kw: object) -> JudgeRequest: base: dict[str, object] = dict( - rule_id="PY-WL-101", message="m", severity="ERROR", - file_path="src/m.py", line=5, qualname="m.f", fingerprint="a" * 64, - taint_summary="actual_return=MIXED_RAW", surrounding_code="def f(): ...", + rule_id="PY-WL-101", + message="m", + severity="ERROR", + file_path="src/m.py", + line=5, + qualname="m.f", + fingerprint="a" * 64, + taint_summary="actual_return=MIXED_RAW", + surrounding_code="def f(): ...", ) base.update(kw) return JudgeRequest(**base) # type: ignore[arg-type] @@ -56,10 +62,14 @@ def test_request_is_frozen() -> None: def test_response_holds_audit_fields() -> None: resp = JudgeResponse( - verdict=JudgeVerdict.FALSE_POSITIVE, rationale="over-taint floor", - confidence=0.9, model_id="anthropic/claude-opus-4-8", - recorded_at=datetime.now(UTC), prompt_tokens_total=100, - prompt_tokens_cached=None, policy_hash="sha256:abc", + verdict=JudgeVerdict.FALSE_POSITIVE, + rationale="over-taint floor", + confidence=0.9, + model_id="anthropic/claude-opus-4-8", + recorded_at=datetime.now(UTC), + prompt_tokens_total=100, + prompt_tokens_cached=None, + policy_hash="sha256:abc", ) assert resp.verdict is JudgeVerdict.FALSE_POSITIVE assert resp.prompt_tokens_cached is None # None != 0 @@ -80,14 +90,20 @@ def test_policy_block_teaches_wardline_model() -> None: def test_policy_hash_is_sha256_of_block() -> None: import hashlib + expect = "sha256:" + hashlib.sha256(_STATIC_POLICY_BLOCK.encode("utf-8")).hexdigest() assert expect == JUDGE_POLICY_HASH def test_build_messages_caches_static_block_and_wraps_untrusted_data() -> None: req = JudgeRequest( - rule_id="PY-WL-101", message="untrusted reaches trusted", severity="ERROR", - file_path="src/m.py", line=5, qualname="m.f", fingerprint="a" * 64, + rule_id="PY-WL-101", + message="untrusted reaches trusted", + severity="ERROR", + file_path="src/m.py", + line=5, + qualname="m.f", + fingerprint="a" * 64, taint_summary="actual_return=MIXED_RAW, declared_return=GUARDED", surrounding_code="def f():\n return user_input", ) @@ -104,14 +120,18 @@ def test_build_messages_caches_static_block_and_wraps_untrusted_data() -> None: def test_build_messages_truncates_long_excerpt() -> None: req = JudgeRequest( - rule_id="PY-WL-103", message="m", severity="WARN", file_path="src/m.py", - line=1, qualname=None, fingerprint="b" * 64, taint_summary="tier=UNKNOWN_RAW", + rule_id="PY-WL-103", + message="m", + severity="WARN", + file_path="src/m.py", + line=1, + qualname=None, + fingerprint="b" * 64, + taint_summary="tier=UNKNOWN_RAW", surrounding_code="x" * 50_000, ) messages = build_messages(req, policy_block=_STATIC_POLICY_BLOCK) - payload = next( - json.loads(b["text"]) for b in messages[1]["content"] if b["text"].lstrip().startswith("{") - ) + payload = next(json.loads(b["text"]) for b in messages[1]["content"] if b["text"].lstrip().startswith("{")) assert payload["surrounding_code"]["truncated"] is True assert len(payload["surrounding_code"]["text"]) <= 12_000 @@ -122,9 +142,13 @@ def test_build_messages_truncates_long_excerpt() -> None: def test_urllib_transport_converts_httperror_to_response(monkeypatch) -> None: def _raise(req, timeout=None): # noqa: ARG001 raise urllib.error.HTTPError( - url=_url(), code=401, msg="Unauthorized", hdrs=None, + url=_url(), + code=401, + msg="Unauthorized", + hdrs=None, fp=io.BytesIO(b'{"error":"bad key"}'), ) + monkeypatch.setattr(urllib.request, "urlopen", _raise) resp = UrllibTransport().post(_url(), b"{}", {"Content-Type": "application/json"}) assert resp.status == 401 and "bad key" in resp.body @@ -151,16 +175,24 @@ def post(self, url, body, headers): # type: ignore[no-untyped-def] return self._response -def _completion(content: str, *, cached: int | None = 7, total: int = 120, - model: str = "anthropic/claude-opus-4-8", finish: str = "stop") -> str: +def _completion( + content: str, + *, + cached: int | None = 7, + total: int = 120, + model: str = "anthropic/claude-opus-4-8", + finish: str = "stop", +) -> str: usage: dict[str, object] = {"prompt_tokens": total} if cached is not None: usage["prompt_tokens_details"] = {"cached_tokens": cached} - return json.dumps({ - "model": model, - "choices": [{"finish_reason": finish, "message": {"content": content}}], - "usage": usage, - }) + return json.dumps( + { + "model": model, + "choices": [{"finish_reason": finish, "message": {"content": content}}], + "usage": usage, + } + ) def _good_verdict() -> str: @@ -213,13 +245,16 @@ def test_call_judge_connection_error_is_transport_error(monkeypatch) -> None: call_judge(_req(), transport=_FakeTransport(exc=ConnectionRefusedError("no"))) -@pytest.mark.parametrize("content", [ - "not json at all", - json.dumps({"verdict": "MAYBE", "rationale": "x", "confidence": 0.5}), - json.dumps({"verdict": "TRUE_POSITIVE", "confidence": 0.5}), - json.dumps({"verdict": "TRUE_POSITIVE", "rationale": "x", "confidence": 2}), - json.dumps({"verdict": "TRUE_POSITIVE", "rationale": "x", "confidence": 0.5, "extra": 1}), -]) +@pytest.mark.parametrize( + "content", + [ + "not json at all", + json.dumps({"verdict": "MAYBE", "rationale": "x", "confidence": 0.5}), + json.dumps({"verdict": "TRUE_POSITIVE", "confidence": 0.5}), + json.dumps({"verdict": "TRUE_POSITIVE", "rationale": "x", "confidence": 2}), + json.dumps({"verdict": "TRUE_POSITIVE", "rationale": "x", "confidence": 0.5, "extra": 1}), + ], +) def test_call_judge_malformed_2xx_crashes(monkeypatch, content: str) -> None: monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "k") with pytest.raises(JudgeContractError): @@ -242,10 +277,12 @@ def test_call_judge_records_served_model_distinct_from_requested(monkeypatch) -> def test_call_judge_falls_back_to_requested_when_served_absent(monkeypatch) -> None: monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "k") - body = json.dumps({ - "choices": [{"finish_reason": "stop", "message": {"content": _good_verdict()}}], - "usage": {"prompt_tokens": 10}, - }) # no "model" key + body = json.dumps( + { + "choices": [{"finish_reason": "stop", "message": {"content": _good_verdict()}}], + "usage": {"prompt_tokens": 10}, + } + ) # no "model" key resp = call_judge(_req(), transport=_FakeTransport(Response(200, body)), model_id="req/model") assert resp.model_id == "req/model" @@ -265,9 +302,12 @@ def test_call_judge_outer_body_non_json_crashes(monkeypatch) -> None: def test_call_judge_missing_usage_degrades_not_crashes(monkeypatch) -> None: # telemetry is NOT the audit primitive: absent usage -> (0, None), verdict still returns. monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "k") - body = json.dumps({ - "model": "m", "choices": [{"finish_reason": "stop", "message": {"content": _good_verdict()}}], - }) # no usage + body = json.dumps( + { + "model": "m", + "choices": [{"finish_reason": "stop", "message": {"content": _good_verdict()}}], + } + ) # no usage resp = call_judge(_req(), transport=_FakeTransport(Response(200, body))) assert resp.verdict is JudgeVerdict.FALSE_POSITIVE assert resp.prompt_tokens_total == 0 and resp.prompt_tokens_cached is None @@ -277,8 +317,15 @@ def test_build_messages_truncation_preserves_head_and_tail() -> None: head, tail = "HEAD" * 50, "TAIL" * 50 code = head + ("x" * 30_000) + tail req = JudgeRequest( - rule_id="PY-WL-101", message="m", severity="ERROR", file_path="src/m.py", line=1, - qualname=None, fingerprint="a" * 64, taint_summary="t", surrounding_code=code, + rule_id="PY-WL-101", + message="m", + severity="ERROR", + file_path="src/m.py", + line=1, + qualname=None, + fingerprint="a" * 64, + taint_summary="t", + surrounding_code=code, ) text = next( json.loads(b["text"])["surrounding_code"]["text"] diff --git a/tests/unit/core/test_judged.py b/tests/unit/core/test_judged.py index 7bbdf651..92324fb5 100644 --- a/tests/unit/core/test_judged.py +++ b/tests/unit/core/test_judged.py @@ -12,9 +12,15 @@ def _fp(**kw: object) -> JudgedFP: base: dict[str, object] = dict( - fingerprint="a" * 64, rule_id="PY-WL-101", path="src/m.py", message="m", - rationale="constructor over-taint floor", model_id="anthropic/claude-opus-4-8", - confidence=0.9, recorded_at=datetime(2026, 5, 30, tzinfo=UTC), policy_hash="sha256:abc", + fingerprint="a" * 64, + rule_id="PY-WL-101", + path="src/m.py", + message="m", + rationale="constructor over-taint floor", + model_id="anthropic/claude-opus-4-8", + confidence=0.9, + recorded_at=datetime(2026, 5, 30, tzinfo=UTC), + policy_hash="sha256:abc", ) base.update(kw) return JudgedFP(**base) # type: ignore[arg-type] @@ -66,10 +72,7 @@ def test_rejudge_updates_existing_record(tmp_path: Path) -> None: def test_missing_provenance_raises(tmp_path: Path) -> None: # model_id / policy_hash / confidence are the audit primitive — never defaulted. path = tmp_path / "judged.yaml" - path.write_text( - "version: 1\nfindings:\n" - f" - fingerprint: {'a' * 64}\n rationale: x\n", encoding="utf-8" - ) + path.write_text(f"version: 1\nfindings:\n - fingerprint: {'a' * 64}\n rationale: x\n", encoding="utf-8") with pytest.raises(ConfigError): load_judged(path) @@ -79,7 +82,8 @@ def test_out_of_range_confidence_raises(tmp_path: Path) -> None: path.write_text( "version: 1\nfindings:\n" f" - fingerprint: {'a' * 64}\n rationale: x\n model_id: m\n" - " policy_hash: sha256:x\n confidence: 1.5\n", encoding="utf-8" + " policy_hash: sha256:x\n confidence: 1.5\n", + encoding="utf-8", ) with pytest.raises(ConfigError): load_judged(path) diff --git a/tests/unit/core/test_qualname.py b/tests/unit/core/test_qualname.py index 37f843e2..e803876a 100644 --- a/tests/unit/core/test_qualname.py +++ b/tests/unit/core/test_qualname.py @@ -40,18 +40,14 @@ def _ancestors(src: str, target: str) -> tuple[str, list[ast.AST]]: def walk(node: ast.AST, scope: list[ast.AST]) -> None: for child in ast.iter_child_nodes(node): - if isinstance( - child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) - ): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): found.append((child, list(scope))) walk(child, [child, *scope]) # prepend -> innermost first else: walk(child, scope) walk(tree, []) - node, ancestors = next( - (n, a) for n, a in found if getattr(n, "name", None) == target - ) + node, ancestors = next((n, a) for n, a in found if getattr(n, "name", None) == target) return node.name, ancestors # type: ignore[attr-defined] diff --git a/tests/unit/core/test_run.py b/tests/unit/core/test_run.py index 52ff957d..9118fa60 100644 --- a/tests/unit/core/test_run.py +++ b/tests/unit/core/test_run.py @@ -29,10 +29,7 @@ def test_run_scan_returns_findings_summary_and_context() -> None: # hold for any fixture regardless of finding count. assert result.summary.total == len(result.findings) # active is the count of non-suppressed DEFECTs (the gate population) - active = sum( - 1 for f in result.findings - if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE - ) + active = sum(1 for f in result.findings if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE) assert result.summary.active == active # context is carried for explain_finding to reuse assert result.context is not None @@ -125,9 +122,7 @@ def test_run_scan_missing_source_root_yields_finding(tmp_path: Path) -> None: # both the CLI summary and the MCP result) and count toward unanalyzed. proj = tmp_path / "proj" proj.mkdir() - (proj / "wardline.yaml").write_text( - "source_roots:\n - does_not_exist\n", encoding="utf-8" - ) + (proj / "wardline.yaml").write_text("source_roots:\n - does_not_exist\n", encoding="utf-8") # discover still warns on a missing root (by design — the CLI keeps the stderr # signal); the NEW contract is that it ALSO becomes a structured finding. with pytest.warns(UserWarning, match="source root does not exist"): diff --git a/tests/unit/core/test_sarif.py b/tests/unit/core/test_sarif.py index 969539c8..e4ce4615 100644 --- a/tests/unit/core/test_sarif.py +++ b/tests/unit/core/test_sarif.py @@ -20,9 +20,15 @@ def _f( qualname: str | None = None, ) -> Finding: return Finding( - rule_id=rule_id, message="msg", severity=sev, kind=kind, + rule_id=rule_id, + message="msg", + severity=sev, + kind=kind, location=Location(path=path, line_start=line_start, line_end=line_start), - fingerprint=fp, suppressed=suppressed, suppression_reason=reason, qualname=qualname, + fingerprint=fp, + suppressed=suppressed, + suppression_reason=reason, + qualname=qualname, ) @@ -37,13 +43,17 @@ def test_log_shape_and_version() -> None: def test_severity_maps_to_level() -> None: levels = { f["properties"]["internalSeverity"]: f["level"] - for f in build_sarif([ - _f(sev=Severity.CRITICAL), _f(sev=Severity.ERROR), - _f(sev=Severity.WARN), _f(sev=Severity.INFO), _f(sev=Severity.NONE), - ])["runs"][0]["results"] + for f in build_sarif( + [ + _f(sev=Severity.CRITICAL), + _f(sev=Severity.ERROR), + _f(sev=Severity.WARN), + _f(sev=Severity.INFO), + _f(sev=Severity.NONE), + ] + )["runs"][0]["results"] } - assert levels == {"CRITICAL": "error", "ERROR": "error", "WARN": "warning", - "INFO": "note", "NONE": "none"} + assert levels == {"CRITICAL": "error", "ERROR": "error", "WARN": "warning", "INFO": "note", "NONE": "none"} def test_partial_fingerprint_and_location() -> None: @@ -75,9 +85,7 @@ def test_rules_array_is_first_seen_unique() -> None: def test_suppressed_finding_emits_suppressions() -> None: baselined = build_sarif([_f(suppressed=SuppressionState.BASELINED)])["runs"][0]["results"][0] assert baselined["suppressions"] == [{"kind": "external", "status": "accepted"}] - waived = build_sarif([ - _f(suppressed=SuppressionState.WAIVED, reason="false positive") - ])["runs"][0]["results"][0] + waived = build_sarif([_f(suppressed=SuppressionState.WAIVED, reason="false positive")])["runs"][0]["results"][0] assert waived["suppressions"][0]["justification"] == "false positive" @@ -99,7 +107,22 @@ def test_sink_writes_valid_json(tmp_path: Path) -> None: assert loaded["version"] == "2.1.0" -def test_judged_finding_emits_suppression() -> None: +def test_metric_findings_excluded_from_sarif() -> None: + """Kind.METRIC findings (engine telemetry) must not appear in SARIF output.""" + metric = _f(rule_id="WLN-L3-LOW-RESOLUTION", sev=Severity.INFO, kind=Kind.METRIC) + defect = _f(rule_id="PY-WL-101", sev=Severity.ERROR, kind=Kind.DEFECT) + fact = _f(rule_id="WLN-ENGINE-UNKNOWN-IMPORT", sev=Severity.NONE, kind=Kind.FACT) + + log = build_sarif([metric, defect, fact]) + results = log["runs"][0]["results"] + rule_ids = {r["ruleId"] for r in results} + + assert "WLN-L3-LOW-RESOLUTION" not in rule_ids, "METRIC finding leaked into SARIF" + assert "PY-WL-101" in rule_ids + assert "WLN-ENGINE-UNKNOWN-IMPORT" in rule_ids + + + res = build_sarif([_f(suppressed=SuppressionState.JUDGED, reason="over-taint floor")])["runs"][0]["results"][0] assert res["suppressions"][0]["kind"] == "external" assert res["suppressions"][0]["justification"] == "over-taint floor" diff --git a/tests/unit/core/test_suppression.py b/tests/unit/core/test_suppression.py index e3e61551..6cc7b2d1 100644 --- a/tests/unit/core/test_suppression.py +++ b/tests/unit/core/test_suppression.py @@ -17,8 +17,12 @@ def _defect(fp: str, *, sev: Severity = Severity.ERROR, kind: Kind = Kind.DEFECT) -> Finding: return Finding( - rule_id="PY-WL-101", message="m", severity=sev, kind=kind, - location=Location(path="src/m.py", line_start=1), fingerprint=fp, + rule_id="PY-WL-101", + message="m", + severity=sev, + kind=kind, + location=Location(path="src/m.py", line_start=1), + fingerprint=fp, ) @@ -33,8 +37,12 @@ def _no_waivers() -> WaiverSet: def test_defect_without_line_start_is_rejected() -> None: # Spec §12 invariant: a DEFECT entering suppression must carry line_start. bad = Finding( - rule_id="PY-WL-101", message="m", severity=Severity.ERROR, kind=Kind.DEFECT, - location=Location(path="src/m.py", line_start=None), fingerprint=_FP_A, + rule_id="PY-WL-101", + message="m", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="src/m.py", line_start=None), + fingerprint=_FP_A, ) with pytest.raises(AssertionError): apply_suppressions([bad], _empty_baseline(), _no_waivers(), today=_TODAY) @@ -44,9 +52,12 @@ def test_engine_diagnostic_defect_without_line_start_surfaces() -> None: # Engine-diagnostic DEFECTs ( path) build a line-independent # fingerprint; the line invariant must NOT apply, and they must surface. eng = Finding( - rule_id="WLN-L3-MONOTONICITY-VIOLATION", message="monotone invariant broke", - severity=Severity.ERROR, kind=Kind.DEFECT, - location=Location(path="", line_start=None), fingerprint=_FP_A, + rule_id="WLN-L3-MONOTONICITY-VIOLATION", + message="monotone invariant broke", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="", line_start=None), + fingerprint=_FP_A, ) out = apply_suppressions([eng], _empty_baseline(), _no_waivers(), today=_TODAY) assert len(out) == 1 @@ -84,15 +95,24 @@ def test_expired_waiver_falls_back_to_active_or_baseline() -> None: def _judged(fp: str) -> JudgedFP: return JudgedFP( - fingerprint=fp, rule_id="PY-WL-101", path="src/m.py", message="m", - rationale="over-taint floor", model_id="m", confidence=0.9, - recorded_at=datetime(2026, 5, 30, tzinfo=UTC), policy_hash="sha256:x", + fingerprint=fp, + rule_id="PY-WL-101", + path="src/m.py", + message="m", + rationale="over-taint floor", + model_id="m", + confidence=0.9, + recorded_at=datetime(2026, 5, 30, tzinfo=UTC), + policy_hash="sha256:x", ) def test_judged_fp_is_suppressed_with_rationale() -> None: out = apply_suppressions( - [_defect(_FP_A)], _empty_baseline(), _no_waivers(), today=_TODAY, + [_defect(_FP_A)], + _empty_baseline(), + _no_waivers(), + today=_TODAY, judged=JudgedSet([_judged(_FP_A)]), ) assert out[0].suppressed is SuppressionState.JUDGED @@ -102,7 +122,10 @@ def test_judged_fp_is_suppressed_with_rationale() -> None: def test_waiver_wins_over_judged() -> None: ws = WaiverSet(parse_waivers([{"fingerprint": _FP_A, "reason": "human waiver"}])) out = apply_suppressions( - [_defect(_FP_A)], _empty_baseline(), ws, today=_TODAY, + [_defect(_FP_A)], + _empty_baseline(), + ws, + today=_TODAY, judged=JudgedSet([_judged(_FP_A)]), ) assert out[0].suppressed is SuppressionState.WAIVED @@ -110,7 +133,10 @@ def test_waiver_wins_over_judged() -> None: def test_judged_wins_over_baseline() -> None: out = apply_suppressions( - [_defect(_FP_A)], Baseline(frozenset({_FP_A})), _no_waivers(), today=_TODAY, + [_defect(_FP_A)], + Baseline(frozenset({_FP_A})), + _no_waivers(), + today=_TODAY, judged=JudgedSet([_judged(_FP_A)]), ) assert out[0].suppressed is SuppressionState.JUDGED @@ -134,6 +160,6 @@ def test_gate_trips_only_on_active_defect_at_or_above_threshold() -> None: def test_gate_ignores_suppressed_and_nondefect_and_none() -> None: baselined = apply_suppressions([_defect(_FP_A)], Baseline(frozenset({_FP_A})), _no_waivers(), today=_TODAY) - assert gate_trips(baselined, Severity.ERROR) is False # suppressed ignored + assert gate_trips(baselined, Severity.ERROR) is False # suppressed ignored assert gate_trips([_defect(_FP_A, kind=Kind.FACT)], Severity.INFO) is False # non-defect ignored assert gate_trips([_defect(_FP_A, sev=Severity.NONE)], Severity.INFO) is False # NONE never gates diff --git a/tests/unit/core/test_triage.py b/tests/unit/core/test_triage.py index f316b959..349ecb63 100644 --- a/tests/unit/core/test_triage.py +++ b/tests/unit/core/test_triage.py @@ -9,17 +9,28 @@ def _defect(fp: str, *, rule: str = "PY-WL-101", active: bool = True) -> Finding: return Finding( - rule_id=rule, message="m", severity=Severity.ERROR, kind=Kind.DEFECT, - location=Location(path="src/m.py", line_start=5, line_end=5), fingerprint=fp, + rule_id=rule, + message="m", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="src/m.py", line_start=5, line_end=5), + fingerprint=fp, properties={"declared_return": "GUARDED", "actual_return": "MIXED_RAW"}, suppressed=SuppressionState.ACTIVE if active else SuppressionState.WAIVED, ) def _resp(v: JudgeVerdict, conf: float = 0.9) -> JudgeResponse: - return JudgeResponse(verdict=v, rationale="r", confidence=conf, model_id="m", - recorded_at=datetime.now(UTC), prompt_tokens_total=1, - prompt_tokens_cached=None, policy_hash="sha256:x") + return JudgeResponse( + verdict=v, + rationale="r", + confidence=conf, + model_id="m", + recorded_at=datetime.now(UTC), + prompt_tokens_total=1, + prompt_tokens_cached=None, + policy_hash="sha256:x", + ) def test_finding_to_request_builds_taint_summary() -> None: @@ -46,8 +57,9 @@ def test_run_triage_splits_tp_and_fp() -> None: def test_run_triage_only_triages_active_defects() -> None: findings = [_defect("a" * 64, active=False)] - result = run_triage(findings, read_excerpt=lambda f: "c", - judge_caller=lambda req: _resp(JudgeVerdict.FALSE_POSITIVE)) + result = run_triage( + findings, read_excerpt=lambda f: "c", judge_caller=lambda req: _resp(JudgeVerdict.FALSE_POSITIVE) + ) assert result.verdicts == [] and result.n_true == 0 and result.n_false == 0 @@ -80,6 +92,7 @@ def caller(req: JudgeRequest) -> JudgeResponse: raise JudgeContractError("model returned garbage") import pytest + with pytest.raises(JudgeContractError): run_triage([_defect("a" * 64)], read_excerpt=lambda f: "c", judge_caller=caller) @@ -90,13 +103,19 @@ def test_run_triage_excerpt_error_skips_and_counts() -> None: def bad_excerpt(f): # type: ignore[no-untyped-def] raise DiscoveryError("unreadable") - result = run_triage([_defect("a" * 64)], read_excerpt=bad_excerpt, - judge_caller=lambda req: _resp(JudgeVerdict.TRUE_POSITIVE)) + result = run_triage( + [_defect("a" * 64)], read_excerpt=bad_excerpt, judge_caller=lambda req: _resp(JudgeVerdict.TRUE_POSITIVE) + ) assert result.n_skipped_excerpt == 1 and result.verdicts == [] def test_run_triage_rejects_nonpositive_max_findings() -> None: import pytest + with pytest.raises(ValueError): - run_triage([_defect("a" * 64)], read_excerpt=lambda f: "c", - judge_caller=lambda req: _resp(JudgeVerdict.TRUE_POSITIVE), max_findings=0) + run_triage( + [_defect("a" * 64)], + read_excerpt=lambda f: "c", + judge_caller=lambda req: _resp(JudgeVerdict.TRUE_POSITIVE), + max_findings=0, + ) diff --git a/tests/unit/core/test_waiver_add.py b/tests/unit/core/test_waiver_add.py index 8ed85a26..b19f33d3 100644 --- a/tests/unit/core/test_waiver_add.py +++ b/tests/unit/core/test_waiver_add.py @@ -12,8 +12,7 @@ def test_add_waiver_creates_config_and_roundtrips(tmp_path: Path) -> None: cfg_path = tmp_path / "wardline.yaml" - w = add_waiver(cfg_path, fingerprint=FP, reason="false positive: validated upstream", - expires=date(2026, 12, 31)) + w = add_waiver(cfg_path, fingerprint=FP, reason="false positive: validated upstream", expires=date(2026, 12, 31)) assert w.fingerprint == FP waivers = parse_waivers(load(cfg_path).waivers) assert any(x.fingerprint == FP and x.expires == date(2026, 12, 31) for x in waivers) diff --git a/tests/unit/core/test_waivers.py b/tests/unit/core/test_waivers.py index 09449265..99d85a28 100644 --- a/tests/unit/core/test_waivers.py +++ b/tests/unit/core/test_waivers.py @@ -55,5 +55,5 @@ def test_match_active_when_no_expiry() -> None: def test_expiry_boundary_inclusive_then_expires() -> None: ws = WaiverSet(parse_waivers([{"fingerprint": _FP, "reason": "r", "expires": "2026-05-30"}])) - assert ws.match(_FP, date(2026, 5, 30)) is not None # valid THROUGH expiry day - assert ws.match(_FP, date(2026, 5, 31)) is None # expired the day after + assert ws.match(_FP, date(2026, 5, 30)) is not None # valid THROUGH expiry day + assert ws.match(_FP, date(2026, 5, 31)) is None # expired the day after diff --git a/tests/unit/decorators/test_base.py b/tests/unit/decorators/test_base.py index bb7702c2..cfba2e81 100644 --- a/tests/unit/decorators/test_base.py +++ b/tests/unit/decorators/test_base.py @@ -29,9 +29,7 @@ def test_apply_marker_stamps_group_and_attrs_and_returns_same_object() -> None: def f() -> int: return 1 - out = apply_marker( - f, name="trusted", group=1, attrs={"_wardline_level": TaintState.INTEGRAL} - ) + out = apply_marker(f, name="trusted", group=1, attrs={"_wardline_level": TaintState.INTEGRAL}) assert out is f # unchanged identity — no wrapper assert f._wardline_groups == frozenset({1}) # type: ignore[attr-defined] assert f._wardline_level is TaintState.INTEGRAL # type: ignore[attr-defined] @@ -60,6 +58,7 @@ def test_apply_marker_rejects_non_callable() -> None: def test_apply_marker_accumulates_groups() -> None: def f() -> None: ... + apply_marker(f, name="external_boundary", group=1, attrs={}) apply_marker(f, name="trusted", group=1, attrs={"_wardline_level": TaintState.INTEGRAL}) assert f._wardline_groups == frozenset({1}) # type: ignore[attr-defined] @@ -70,10 +69,9 @@ def test_apply_marker_double_decoration_preserves_distinct_attrs() -> None: # (Cross-group accumulation is untestable until a group-2 registry entry # exists; only group 1 is defined in SP2a.) def f() -> None: ... + apply_marker(f, name="trusted", group=1, attrs={"_wardline_level": TaintState.ASSURED}) - apply_marker( - f, name="trust_boundary", group=1, attrs={"_wardline_to_level": TaintState.GUARDED} - ) + apply_marker(f, name="trust_boundary", group=1, attrs={"_wardline_to_level": TaintState.GUARDED}) assert f._wardline_level is TaintState.ASSURED # type: ignore[attr-defined] assert f._wardline_to_level is TaintState.GUARDED # type: ignore[attr-defined] @@ -90,9 +88,7 @@ def _impl(cls: object) -> int: return 1 cm = classmethod(_impl) - out = apply_marker( - cm, name="trusted", group=1, attrs={"_wardline_level": TaintState.INTEGRAL} - ) + out = apply_marker(cm, name="trusted", group=1, attrs={"_wardline_level": TaintState.INTEGRAL}) assert out is cm assert cm.__func__._wardline_level is TaintState.INTEGRAL # type: ignore[attr-defined] diff --git a/tests/unit/decorators/test_trust.py b/tests/unit/decorators/test_trust.py index 59ce0aeb..16663526 100644 --- a/tests/unit/decorators/test_trust.py +++ b/tests/unit/decorators/test_trust.py @@ -39,6 +39,7 @@ def g() -> None: ... def test_trusted_rejects_disallowed_level() -> None: with pytest.raises(ValueError, match="must be one of"): + @trusted(level="GUARDED") # not a trusted-producer level def f() -> None: ... @@ -63,6 +64,7 @@ def shape(x: object) -> object: def test_trust_boundary_rejects_integral() -> None: with pytest.raises(ValueError, match="must be one of"): + @trust_boundary(to_level="INTEGRAL") # boundaries raise to GUARDED/ASSURED only def f(x: object) -> object: return x @@ -71,4 +73,5 @@ def f(x: object) -> object: def test_decorators_preserve_qualname() -> None: @trusted def named() -> None: ... + assert named.__name__ == "named" diff --git a/tests/unit/install/test_block.py b/tests/unit/install/test_block.py index 292222b1..08ddfe02 100644 --- a/tests/unit/install/test_block.py +++ b/tests/unit/install/test_block.py @@ -37,8 +37,7 @@ def test_reinject_same_version_is_unchanged(tmp_path: Path) -> None: def test_inject_replaces_a_stale_fenced_block(tmp_path: Path) -> None: f = tmp_path / "CLAUDE.md" f.write_text( - "intro\n\n\nOLD BODY\n" - "\n\noutro\n", + "intro\n\n\nOLD BODY\n\n\noutro\n", encoding="utf-8", ) assert inject_block(f) == "updated" diff --git a/tests/unit/install/test_detect.py b/tests/unit/install/test_detect.py index e616d901..a94419d3 100644 --- a/tests/unit/install/test_detect.py +++ b/tests/unit/install/test_detect.py @@ -38,9 +38,7 @@ def test_existing_key_left_untouched(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("WARDLINE_CLARION_URL", "http://new") monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) - (tmp_path / "wardline.yaml").write_text( - 'clarion:\n url: "http://existing"\n', encoding="utf-8" - ) + (tmp_path / "wardline.yaml").write_text('clarion:\n url: "http://existing"\n', encoding="utf-8") results = record_bindings(tmp_path) assert results["clarion"] == "present (left untouched)" text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") diff --git a/tests/unit/install/test_mcp_json.py b/tests/unit/install/test_mcp_json.py index 4671dbe4..eb793dcf 100644 --- a/tests/unit/install/test_mcp_json.py +++ b/tests/unit/install/test_mcp_json.py @@ -40,17 +40,13 @@ def test_malformed_json_raises_without_clobbering(tmp_path: Path) -> None: def test_mcpservers_non_dict_raises(tmp_path: Path) -> None: - (tmp_path / ".mcp.json").write_text( - json.dumps({"mcpServers": []}), encoding="utf-8" - ) + (tmp_path / ".mcp.json").write_text(json.dumps({"mcpServers": []}), encoding="utf-8") with pytest.raises(WardlineError): merge_mcp_entry(tmp_path) def test_mcpservers_null_is_treated_as_absent(tmp_path: Path) -> None: - (tmp_path / ".mcp.json").write_text( - json.dumps({"mcpServers": None, "other": 1}), encoding="utf-8" - ) + (tmp_path / ".mcp.json").write_text(json.dumps({"mcpServers": None, "other": 1}), encoding="utf-8") assert merge_mcp_entry(tmp_path) == "updated" data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY diff --git a/tests/unit/mcp/test_protocol.py b/tests/unit/mcp/test_protocol.py index b31c7eb0..ac9d21a2 100644 --- a/tests/unit/mcp/test_protocol.py +++ b/tests/unit/mcp/test_protocol.py @@ -12,8 +12,14 @@ def _server() -> JsonRpcServer: def test_initialize_returns_capabilities_and_protocol_version() -> None: srv = _server() - resp = srv.dispatch({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}}) + resp = srv.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}, + } + ) assert resp["jsonrpc"] == "2.0" assert resp["id"] == 1 assert resp["result"]["protocolVersion"] == PROTOCOL_VERSION @@ -51,7 +57,7 @@ def test_run_stdio_loop_frames_and_skips_notifications() -> None: srv = _server() stdin = io.StringIO( "this is not json\n" # -> parse error, one response - '\n' # blank line, skipped entirely + "\n" # blank line, skipped entirely '{"jsonrpc": "2.0", "method": "notifications/initialized"}\n' # notification, no response '{"jsonrpc": "2.0", "id": 7, "method": "ping", "params": {"n": 1}}\n' # -> ping result ) diff --git a/tests/unit/mcp/test_server_clarion_write.py b/tests/unit/mcp/test_server_clarion_write.py index 13721039..bdf7511f 100644 --- a/tests/unit/mcp/test_server_clarion_write.py +++ b/tests/unit/mcp/test_server_clarion_write.py @@ -52,15 +52,20 @@ def test_scan_tool_survives_clarion_write_error(tmp_path): def _fresh_view(proj, qualname, callee_qualname): h = blake3.blake3((proj / "svc.py").read_bytes()).hexdigest() blob = { - "schema_version": "wardline-taint-1", "qualname": qualname, + "schema_version": "wardline-taint-1", + "qualname": qualname, "content_hash_at_compute": h, - "taint": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW", - "source": "anchored", "contributing_callee_qualname": callee_qualname, - "resolved_call_count": 1, "unresolved_call_count": 0}, + "taint": { + "declared_return": "INTEGRAL", + "actual_return": "EXTERNAL_RAW", + "source": "anchored", + "contributing_callee_qualname": callee_qualname, + "resolved_call_count": 1, + "unresolved_call_count": 0, + }, "findings": [], } - return TaintFactView(qualname=qualname, exists=True, wardline_json=blob, - current_content_hash=h) + return TaintFactView(qualname=qualname, exists=True, wardline_json=blob, current_content_hash=h) class MapClient: @@ -75,13 +80,13 @@ def test_explain_taint_chain_block_with_store(tmp_path): # chain:true + a configured store walks the full taint chain (leaky -> read_raw # boundary leaf) and surfaces it as a `chain` block alongside the single-hop fields. (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") - client = MapClient({ - "svc.leaky": _fresh_view(tmp_path, "svc.leaky", "svc.read_raw"), - "svc.read_raw": _fresh_view(tmp_path, "svc.read_raw", None), - }) - out = _explain_taint( - {"sink_qualname": "svc.leaky", "chain": True}, tmp_path, client + client = MapClient( + { + "svc.leaky": _fresh_view(tmp_path, "svc.leaky", "svc.read_raw"), + "svc.read_raw": _fresh_view(tmp_path, "svc.read_raw", None), + } ) + out = _explain_taint({"sink_qualname": "svc.leaky", "chain": True}, tmp_path, client) assert "chain" in out assert [h["qualname"] for h in out["chain"]["hops"]] == ["svc.leaky", "svc.read_raw"] assert out["chain"]["truncated_at"] is None diff --git a/tests/unit/mcp/test_server_resources.py b/tests/unit/mcp/test_server_resources.py index 161d425a..89d22335 100644 --- a/tests/unit/mcp/test_server_resources.py +++ b/tests/unit/mcp/test_server_resources.py @@ -11,8 +11,7 @@ def test_resources_list_has_the_four_stable_resources() -> None: resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "resources/list", "params": {}}) resources = resp["result"]["resources"] uris = {r["uri"] for r in resources} - assert uris == {"wardline://vocab", "wardline://rules", - "wardline://config", "wardline://config-schema"} + assert uris == {"wardline://vocab", "wardline://rules", "wardline://config", "wardline://config-schema"} for r in resources: assert r["name"].strip() assert r["mimeType"].strip() @@ -27,8 +26,9 @@ def test_findings_are_not_a_resource() -> None: def test_read_config_schema_returns_json_schema() -> None: server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 2, "method": "resources/read", - "params": {"uri": "wardline://config-schema"}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "wardline://config-schema"}} + ) contents = resp["result"]["contents"][0] schema = json.loads(contents["text"]) assert schema["$schema"].startswith("https://json-schema.org/") @@ -36,8 +36,9 @@ def test_read_config_schema_returns_json_schema() -> None: def test_read_rules_lists_rule_ids() -> None: server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 3, "method": "resources/read", - "params": {"uri": "wardline://rules"}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 3, "method": "resources/read", "params": {"uri": "wardline://rules"}} + ) payload = json.loads(resp["result"]["contents"][0]["text"]) assert isinstance(payload["rules"], list) and payload["rules"] for r in payload["rules"]: @@ -54,8 +55,9 @@ def test_read_rules_lists_rule_ids() -> None: def test_read_config_returns_effective_fields() -> None: server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 5, "method": "resources/read", - "params": {"uri": "wardline://config"}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": {"uri": "wardline://config"}} + ) payload = json.loads(resp["result"]["contents"][0]["text"]) for key in ("source_roots", "exclude", "rules_enable", "rules_severity"): assert key in payload @@ -64,6 +66,7 @@ def test_read_config_returns_effective_fields() -> None: def test_read_unknown_uri_errors() -> None: server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 4, "method": "resources/read", - "params": {"uri": "wardline://nope"}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": {"uri": "wardline://nope"}} + ) assert "error" in resp diff --git a/tests/unit/mcp/test_server_security.py b/tests/unit/mcp/test_server_security.py index 7a069c5a..5ad20d9f 100644 --- a/tests/unit/mcp/test_server_security.py +++ b/tests/unit/mcp/test_server_security.py @@ -22,8 +22,9 @@ def _dispatch(server, name, arguments): - return server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": name, "arguments": arguments}}) + return server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}} + ) def _assert_iserror(resp, needle: str) -> None: @@ -55,8 +56,7 @@ def test_explain_taint_out_of_root_path_is_iserror(tmp_path: Path) -> None: def test_baseline_create_config_escape_is_iserror(tmp_path: Path) -> None: server = WardlineMCPServer(root=tmp_path) - resp = _dispatch(server, "baseline_create", - {"reason": "x", "config": "../../outside.yaml"}) + resp = _dispatch(server, "baseline_create", {"reason": "x", "config": "../../outside.yaml"}) _assert_iserror(resp, "within the project root") diff --git a/tests/unit/mcp/test_server_suppression.py b/tests/unit/mcp/test_server_suppression.py index 0fb528f4..ca5e3567 100644 --- a/tests/unit/mcp/test_server_suppression.py +++ b/tests/unit/mcp/test_server_suppression.py @@ -30,8 +30,9 @@ def _leaky_project(tmp_path: Path) -> Path: def _call(server: WardlineMCPServer, name: str, arguments: dict) -> dict: - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": name, "arguments": arguments}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}} + ) assert "error" not in resp, resp assert not resp["result"].get("isError"), resp # tool-execution error return json.loads(resp["result"]["content"][0]["text"]) @@ -40,8 +41,9 @@ def _call(server: WardlineMCPServer, name: str, arguments: dict) -> dict: def test_baseline_create_requires_reason(tmp_path: Path) -> None: proj = _leaky_project(tmp_path) server = WardlineMCPServer(root=proj) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "baseline_create", "arguments": {}}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "baseline_create", "arguments": {}}} + ) # reason is mandatory -> tool-execution error (isError result) assert resp["result"]["isError"] is True assert "reason" in resp["result"]["content"][0]["text"].lower() @@ -62,9 +64,14 @@ def test_baseline_create_refuses_when_exists(tmp_path: Path) -> None: _call(server, "baseline_create", {"reason": "accept current debt"}) # A second create must not clobber: refuse path -> agent-actionable isError, not # an opaque JSON-RPC fault with a bare file path. - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "baseline_create", - "arguments": {"reason": "again"}}}) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "baseline_create", "arguments": {"reason": "again"}}, + } + ) assert "error" not in resp, resp assert resp["result"]["isError"] is True assert "baseline_update" in resp["result"]["content"][0]["text"] @@ -74,13 +81,17 @@ def test_waiver_add_requires_reason_and_expires(tmp_path: Path) -> None: proj = _leaky_project(tmp_path) server = WardlineMCPServer(root=proj) fp = "b" * 64 - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "waiver_add", - "arguments": {"fingerprint": fp, "reason": "ok"}}}) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "waiver_add", "arguments": {"fingerprint": fp, "reason": "ok"}}, + } + ) # expires is mandatory at the tool boundary -> isError result assert resp["result"]["isError"] is True - out = _call(server, "waiver_add", - {"fingerprint": fp, "reason": "validated upstream", "expires": "2026-12-31"}) + out = _call(server, "waiver_add", {"fingerprint": fp, "reason": "validated upstream", "expires": "2026-12-31"}) assert out["fingerprint"] == fp @@ -88,10 +99,14 @@ def test_waiver_add_bad_date_is_iserror(tmp_path: Path) -> None: proj = _leaky_project(tmp_path) server = WardlineMCPServer(root=proj) fp = "c" * 64 - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "waiver_add", - "arguments": {"fingerprint": fp, "reason": "ok", - "expires": "not-a-date"}}}) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "waiver_add", "arguments": {"fingerprint": fp, "reason": "ok", "expires": "not-a-date"}}, + } + ) # A malformed date is agent-actionable -> isError with YYYY-MM-DD guidance. assert resp["result"]["isError"] is True assert "yyyy-mm-dd" in resp["result"]["content"][0]["text"].lower() @@ -112,8 +127,9 @@ def test_prompts_list_has_loop() -> None: def test_prompts_get_loop_returns_message() -> None: server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "prompts/get", - "params": {"name": "wardline:loop"}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "prompts/get", "params": {"name": "wardline:loop"}} + ) assert "error" not in resp, resp msg = resp["result"]["messages"][0] assert msg["role"] == "user" @@ -122,8 +138,7 @@ def test_prompts_get_loop_returns_message() -> None: def test_prompts_get_unknown_is_jsonrpc_error() -> None: server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "prompts/get", - "params": {"name": "nope"}}) + resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "prompts/get", "params": {"name": "nope"}}) # An unknown prompt name is a caller bug -> JSON-RPC error, not an isError result. assert "error" in resp @@ -141,13 +156,10 @@ def _fake_response() -> JudgeResponse: ) -def test_judge_tool_success_via_monkeypatch( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_judge_tool_success_via_monkeypatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # run_judge builds its default caller from call_judge imported INTO the # judge_run namespace; patch it there so the network is never touched. - monkeypatch.setattr("wardline.core.judge_run.call_judge", - lambda *a, **k: _fake_response()) + monkeypatch.setattr("wardline.core.judge_run.call_judge", lambda *a, **k: _fake_response()) # call_judge is patched out, and the env-key check lives inside it — so no key is # actually read and no network is hit. The setenv just keeps the default-caller # construction path representative; it is not load-bearing for this test. @@ -163,14 +175,13 @@ def test_judge_tool_success_via_monkeypatch( assert v["fingerprint"] -def test_judge_tool_missing_key_is_iserror_with_guidance( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_judge_tool_missing_key_is_iserror_with_guidance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("WARDLINE_OPENROUTER_API_KEY", raising=False) proj = _leaky_project(tmp_path) # no .env in this bare project server = WardlineMCPServer(root=proj) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "judge", "arguments": {}}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "judge", "arguments": {}}} + ) # Missing key reaches the agent as readable guidance, not a swallowed JSON-RPC error. assert "error" not in resp, resp assert resp["result"]["isError"] is True diff --git a/tests/unit/mcp/test_server_tools.py b/tests/unit/mcp/test_server_tools.py index 9962907a..e87affad 100644 --- a/tests/unit/mcp/test_server_tools.py +++ b/tests/unit/mcp/test_server_tools.py @@ -25,8 +25,9 @@ def _leaky_project(tmp_path: Path) -> Path: def _call(server, name, arguments): srv = server.rpc - resp = srv.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": name, "arguments": arguments}}) + resp = srv.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}} + ) assert "error" not in resp, resp # MCP wraps tool output as content[0].text holding JSON text = resp["result"]["content"][0]["text"] @@ -78,9 +79,14 @@ def test_explain_taint_success_through_mcp(tmp_path: Path) -> None: leak = next(f for f in scan_out["findings"] if f["rule_id"] == "PY-WL-101") fp = leak["fingerprint"] - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 5, "method": "tools/call", - "params": {"name": "explain_taint", - "arguments": {"fingerprint": fp}}}) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": {"name": "explain_taint", "arguments": {"fingerprint": fp}}, + } + ) assert "error" not in resp, resp assert resp["result"].get("isError") is not True, resp["result"] out = json.loads(resp["result"]["content"][0]["text"]) @@ -90,8 +96,7 @@ def test_explain_taint_success_through_mcp(tmp_path: Path) -> None: # path+line success case: the path is a MATCH KEY (relative posix), confined # for escape-rejection but passed through unmodified to match the finding. - out2 = _call(server, "explain_taint", - {"path": out["location"]["path"], "line": out["location"]["line"]}) + out2 = _call(server, "explain_taint", {"path": out["location"]["path"], "line": out["location"]["line"]}) assert out2["fingerprint"] == fp @@ -100,9 +105,14 @@ def test_explain_taint_unknown_fingerprint_is_an_iserror_result() -> None: # it returns as an isError RESULT (content the client reliably surfaces), NOT a # JSON-RPC error that clients may swallow as a transport fault. server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 9, "method": "tools/call", - "params": {"name": "explain_taint", - "arguments": {"fingerprint": "0" * 64}}}) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": {"name": "explain_taint", "arguments": {"fingerprint": "0" * 64}}, + } + ) assert "error" not in resp, resp assert resp["result"]["isError"] is True assert "re-scan" in resp["result"]["content"][0]["text"].lower() @@ -118,10 +128,10 @@ def test_unexpected_handler_exception_is_an_iserror_result() -> None: def boom(args: dict[str, Any], root: Path) -> Any: raise ValueError("taint engine exploded") - server.add_tool(Tool(name="boom", description="", input_schema={"type": "object"}, - handler=boom)) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 13, "method": "tools/call", - "params": {"name": "boom", "arguments": {}}}) + server.add_tool(Tool(name="boom", description="", input_schema={"type": "object"}, handler=boom)) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "boom", "arguments": {}}} + ) assert "error" not in resp, resp assert resp["result"]["isError"] is True text = resp["result"]["content"][0]["text"] @@ -138,10 +148,10 @@ def test_handler_raising_mcperror_stays_a_jsonrpc_error() -> None: def proto_fault(args: dict[str, Any], root: Path) -> Any: raise McpError("deliberate protocol fault") - server.add_tool(Tool(name="pf", description="", input_schema={"type": "object"}, - handler=proto_fault)) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 14, "method": "tools/call", - "params": {"name": "pf", "arguments": {}}}) + server.add_tool(Tool(name="pf", description="", input_schema={"type": "object"}, handler=proto_fault)) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 14, "method": "tools/call", "params": {"name": "pf", "arguments": {}}} + ) assert "result" not in resp, resp assert resp["error"]["code"] == -32603 assert "deliberate protocol fault" in resp["error"]["message"] @@ -151,8 +161,9 @@ def test_unknown_tool_name_is_a_jsonrpc_error() -> None: # A genuinely-unknown tool name is a PROTOCOL fault (caller bug), so it stays a # JSON-RPC error — not an isError result. server = WardlineMCPServer(root=FIXTURE) - resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 11, "method": "tools/call", - "params": {"name": "does_not_exist", "arguments": {}}}) + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "does_not_exist", "arguments": {}}} + ) assert "result" not in resp, resp assert resp["error"]["code"] == -32603 assert "does_not_exist" in resp["error"]["message"] diff --git a/tests/unit/scanner/rules/test_ast_helpers.py b/tests/unit/scanner/rules/test_ast_helpers.py index 2f66cfd6..f7f93b89 100644 --- a/tests/unit/scanner/rules/test_ast_helpers.py +++ b/tests/unit/scanner/rules/test_ast_helpers.py @@ -43,7 +43,7 @@ def handler(src: str) -> ast.ExceptHandler: fn = _fn("def f():\n try:\n a()\n" + src) return next(own_except_handlers(fn)) - assert is_broad_except(handler(" except:\n pass\n")) # bare + assert is_broad_except(handler(" except:\n pass\n")) # bare assert is_broad_except(handler(" except Exception:\n pass\n")) assert is_broad_except(handler(" except BaseException:\n pass\n")) assert not is_broad_except(handler(" except ValueError:\n pass\n")) diff --git a/tests/unit/scanner/rules/test_boundary_without_rejection.py b/tests/unit/scanner/rules/test_boundary_without_rejection.py index 4579a8ad..9308a07f 100644 --- a/tests/unit/scanner/rules/test_boundary_without_rejection.py +++ b/tests/unit/scanner/rules/test_boundary_without_rejection.py @@ -28,41 +28,53 @@ def _run(ctx): def test_boundary_without_rejection_fires(tmp_path) -> None: # @trust_boundary that just returns its input — cannot reject -> DEFECT. - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='ASSURED')\ndef v(p):\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n return p\n", + }, + ) findings = _run(ctx) assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-102", "m.v")] assert findings[0].kind == Kind.DEFECT def test_boundary_with_raise_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='ASSURED')\n" - "def v(p):\n if not p:\n raise ValueError\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n if not p:\n raise ValueError\n return p\n", + }, + ) assert _run(ctx) == [] def test_boundary_with_falsy_return_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='GUARDED')\n" - "def v(p):\n if not p:\n return None\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='GUARDED')\n" + "def v(p):\n if not p:\n return None\n return p\n", + }, + ) assert _run(ctx) == [] def test_non_boundary_decorators_are_ignored(tmp_path) -> None: # @trusted (body == return, not a trust-raising transition) and @external_boundary # are NOT trust boundaries -> never flagged by PY-WL-102. - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted, external_boundary\n" - "@trusted\ndef a():\n return 1\n" - "@external_boundary\ndef b(p):\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted, external_boundary\n" + "@trusted\ndef a():\n return 1\n" + "@external_boundary\ndef b(p):\n return p\n", + }, + ) assert _run(ctx) == [] diff --git a/tests/unit/scanner/rules/test_broad_exception.py b/tests/unit/scanner/rules/test_broad_exception.py index 48210efd..21160c41 100644 --- a/tests/unit/scanner/rules/test_broad_exception.py +++ b/tests/unit/scanner/rules/test_broad_exception.py @@ -27,10 +27,13 @@ def _run(ctx): def test_broad_except_in_trusted_fires_at_base(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n except Exception:\n h()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n except Exception:\n h()\n", + }, + ) findings = _run(ctx) assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-103", "m.f")] assert findings[0].kind == Kind.DEFECT @@ -39,42 +42,57 @@ def test_broad_except_in_trusted_fires_at_base(tmp_path) -> None: def test_broad_except_in_undecorated_is_suppressed(tmp_path) -> None: # Undecorated -> UNKNOWN_RAW (freedom zone) -> modulate to NONE -> no finding. - ctx, _ = _analyze(tmp_path, { - "m.py": "def f():\n try:\n g()\n except Exception:\n h()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "def f():\n try:\n g()\n except Exception:\n h()\n", + }, + ) assert _run(ctx) == [] def test_bare_except_fires(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n except:\n h()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n except:\n h()\n", + }, + ) assert [f.rule_id for f in _run(ctx)] == ["PY-WL-103"] def test_specific_except_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n except ValueError:\n h()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n except ValueError:\n h()\n", + }, + ) assert _run(ctx) == [] def test_tuple_containing_broad_name_fires(tmp_path) -> None: # `except (Exception, OSError)` is just as broad as `except Exception`. - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n" - " except (Exception, OSError):\n h()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n" + " except (Exception, OSError):\n h()\n", + }, + ) assert [f.rule_id for f in _run(ctx)] == ["PY-WL-103"] def test_specific_tuple_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n" - " except (KeyError, IndexError):\n h()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n" + " except (KeyError, IndexError):\n h()\n", + }, + ) assert _run(ctx) == [] diff --git a/tests/unit/scanner/rules/test_default_registry.py b/tests/unit/scanner/rules/test_default_registry.py index e93b777f..ebc8ee96 100644 --- a/tests/unit/scanner/rules/test_default_registry.py +++ b/tests/unit/scanner/rules/test_default_registry.py @@ -36,20 +36,21 @@ def test_rules_enable_filters() -> None: def test_rules_severity_overrides_base() -> None: - reg = build_default_registry( - WardlineConfig(rules_severity={"PY-WL-103": "CRITICAL"}) - ) + reg = build_default_registry(WardlineConfig(rules_severity={"PY-WL-103": "CRITICAL"})) rule = next(r for r in reg.rules if r.rule_id == "PY-WL-103") assert rule.base_severity == Severity.CRITICAL def test_analyzer_runs_default_rules_end_to_end(tmp_path) -> None: # A @trusted function that leaks raw -> the analyzer (default registry) emits PY-WL-101. - _, findings = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n", - "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" - "@trusted\ndef leaky(p):\n return read_raw(p)\n", - }) + _, findings = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n", + }, + ) defects = [f for f in findings if f.kind == Kind.DEFECT] assert any(f.rule_id == "PY-WL-101" and f.qualname == "svc.leaky" for f in defects) diff --git a/tests/unit/scanner/rules/test_silent_exception.py b/tests/unit/scanner/rules/test_silent_exception.py index b786ef58..0268323e 100644 --- a/tests/unit/scanner/rules/test_silent_exception.py +++ b/tests/unit/scanner/rules/test_silent_exception.py @@ -27,33 +27,45 @@ def _run(ctx): def test_silent_handler_in_trusted_fires(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n except ValueError:\n pass\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n except ValueError:\n pass\n", + }, + ) findings = _run(ctx) assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-104", "m.f")] assert findings[0].severity == Severity.WARN # base, trusted tier unchanged def test_silent_handler_in_undecorated_is_suppressed(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "def f():\n try:\n g()\n except ValueError:\n pass\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "def f():\n try:\n g()\n except ValueError:\n pass\n", + }, + ) assert _run(ctx) == [] def test_handled_exception_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n except ValueError:\n log()\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n except ValueError:\n log()\n", + }, + ) assert _run(ctx) == [] def test_reraise_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n" - "@trusted\ndef f():\n try:\n g()\n except ValueError:\n raise\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n try:\n g()\n except ValueError:\n raise\n", + }, + ) assert _run(ctx) == [] diff --git a/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py b/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py index ee750110..133fcd36 100644 --- a/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py +++ b/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py @@ -27,13 +27,16 @@ def _run(ctx) -> list: def test_trusted_returning_raw_fires(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n", - "svc.py": "from wardline.decorators import trusted\n" - "from io import read_raw\n" - "@trusted\ndef leaky(p):\n return read_raw(p)\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\n" + "from io import read_raw\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n", + }, + ) findings = _run(ctx) ids = {(f.rule_id, f.qualname) for f in findings} assert ("PY-WL-101", "svc.leaky") in ids @@ -42,25 +45,31 @@ def test_trusted_returning_raw_fires(tmp_path) -> None: def test_trusted_returning_validated_is_clean(tmp_path) -> None: # @trusted(ASSURED) returning a @trust_boundary(ASSURED) result == declared; no fire. - ctx, _ = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary, trust_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n" - "@trust_boundary(to_level='ASSURED')\n" - "def validate(p):\n if not p:\n raise ValueError\n return p\n", - "svc.py": "from wardline.decorators import trusted\n" - "from io import read_raw, validate\n" - "@trusted(level='ASSURED')\ndef safe(p):\n return validate(read_raw(p))\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary, trust_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trust_boundary(to_level='ASSURED')\n" + "def validate(p):\n if not p:\n raise ValueError\n return p\n", + "svc.py": "from wardline.decorators import trusted\n" + "from io import read_raw, validate\n" + "@trusted(level='ASSURED')\ndef safe(p):\n return validate(read_raw(p))\n", + }, + ) assert _run(ctx) == [] def test_external_boundary_returning_raw_is_gated_out(tmp_path) -> None: # @external_boundary's declared return is EXTERNAL_RAW (raw zone) -> trust-claim # gate excludes it even though it returns raw data. (Idiomatic boundary code.) - ctx, _ = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary\n" - "@external_boundary\ndef handler(p):\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef handler(p):\n return p\n", + }, + ) assert _run(ctx) == [] @@ -70,9 +79,12 @@ def test_undecorated_is_silent(tmp_path) -> None: def test_trusted_returning_constant_is_clean(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trusted\n@trusted\ndef f():\n return 1\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n@trusted\ndef f():\n return 1\n", + }, + ) assert _run(ctx) == [] @@ -80,25 +92,31 @@ def test_trusted_leaking_raw_via_except_handler_fires(tmp_path) -> None: # Regression (under-taint): a @trusted function that leaks raw only through an # except handler. The handler's return must be collected, or PY-WL-101 stays # silent on a real leak. - ctx, _ = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n", - "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" - "@trusted\ndef leaky(p):\n try:\n return 1\n" - " except ValueError:\n return read_raw(p)\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" + "@trusted\ndef leaky(p):\n try:\n return 1\n" + " except ValueError:\n return read_raw(p)\n", + }, + ) assert ("PY-WL-101", "svc.leaky") in {(f.rule_id, f.qualname) for f in _run(ctx)} def test_trusted_leaking_raw_via_match_arm_fires(tmp_path) -> None: # Regression (under-taint): leak reachable only through a match arm. - ctx, _ = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n", - "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" - "@trusted\ndef leaky(p):\n match p:\n case 1:\n" - " return read_raw(p)\n case _:\n return 1\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" + "@trusted\ndef leaky(p):\n match p:\n case 1:\n" + " return read_raw(p)\n case _:\n return 1\n", + }, + ) assert ("PY-WL-101", "svc.leaky") in {(f.rule_id, f.qualname) for f in _run(ctx)} @@ -106,14 +124,17 @@ def test_trusted_leaking_raw_via_match_arm_assignment_fires(tmp_path) -> None: # The closed L2 gap: a @trusted function that assigns raw to a local inside a # match arm and returns the var LATER (not a direct return in the arm). Before # L2 match-handling this was a fail-open under-taint that PY-WL-101 missed. - ctx, _ = _analyze(tmp_path, { - "io.py": "from wardline.decorators import external_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n", - "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" - "@trusted\ndef leaky(p):\n x = 1\n match p:\n" - " case 1:\n x = read_raw(p)\n" - " case _:\n x = 2\n return x\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\nfrom io import read_raw\n" + "@trusted\ndef leaky(p):\n x = 1\n match p:\n" + " case 1:\n x = read_raw(p)\n" + " case _:\n x = 2\n return x\n", + }, + ) findings = _run(ctx) assert ("PY-WL-101", "svc.leaky") in {(f.rule_id, f.qualname) for f in findings} assert all(f.kind == Kind.DEFECT for f in findings) @@ -125,32 +146,38 @@ def test_trusted_method_leaking_raw_via_self_method_fires(tmp_path) -> None: # self.* / cls.* method call sites (only top-level functions were keyed), so # the call resolved to function_taint — a fail-open launder. L3 builds these # edges via resolve_self_method_fqn; L2 now has parity. - ctx, _ = _analyze(tmp_path, { - "svc.py": "from wardline.decorators import trusted, external_boundary\n" - "class S:\n" - " @external_boundary\n" - " def raw(self, p):\n" - " return p\n" - " @trusted\n" - " def m(self, p):\n" - " return self.raw(p)\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "svc.py": "from wardline.decorators import trusted, external_boundary\n" + "class S:\n" + " @external_boundary\n" + " def raw(self, p):\n" + " return p\n" + " @trusted\n" + " def m(self, p):\n" + " return self.raw(p)\n", + }, + ) ids = {(f.rule_id, f.qualname) for f in _run(ctx)} assert ("PY-WL-101", "svc.S.m") in ids def test_trusted_method_leaking_raw_via_cls_method_fires(tmp_path) -> None: # PART C: same, but the call goes through ``cls.``. - ctx, _ = _analyze(tmp_path, { - "svc.py": "from wardline.decorators import trusted, external_boundary\n" - "class S:\n" - " @external_boundary\n" - " def raw(cls, p):\n" - " return p\n" - " @trusted\n" - " def m(cls, p):\n" - " return cls.raw(p)\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "svc.py": "from wardline.decorators import trusted, external_boundary\n" + "class S:\n" + " @external_boundary\n" + " def raw(cls, p):\n" + " return p\n" + " @trusted\n" + " def m(cls, p):\n" + " return cls.raw(p)\n", + }, + ) ids = {(f.rule_id, f.qualname) for f in _run(ctx)} assert ("PY-WL-101", "svc.S.m") in ids @@ -159,18 +186,21 @@ def test_trusted_method_calling_validating_self_method_is_clean(tmp_path) -> Non # PART C clean counterpart: the self-method is a @trust_boundary validator # returning ASSURED; the @trusted(ASSURED) caller returns its result == declared. # Must NOT fire PY-WL-101 (no false positive from the new self.* edge). - ctx, _ = _analyze(tmp_path, { - "svc.py": "from wardline.decorators import trusted, trust_boundary\n" - "class S:\n" - " @trust_boundary(to_level='ASSURED')\n" - " def validate(self, p):\n" - " if not p:\n" - " raise ValueError\n" - " return p\n" - " @trusted(level='ASSURED')\n" - " def m(self, p):\n" - " return self.validate(p)\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "svc.py": "from wardline.decorators import trusted, trust_boundary\n" + "class S:\n" + " @trust_boundary(to_level='ASSURED')\n" + " def validate(self, p):\n" + " if not p:\n" + " raise ValueError\n" + " return p\n" + " @trusted(level='ASSURED')\n" + " def m(self, p):\n" + " return self.validate(p)\n", + }, + ) assert ("PY-WL-101", "svc.S.m") not in {(f.rule_id, f.qualname) for f in _run(ctx)} @@ -183,18 +213,22 @@ def test_correct_trust_boundary_does_not_fire_101(tmp_path) -> None: # PY-WL-101 (exempt -> 102's domain) NOR PY-WL-102 (it HAS a rejection path). from wardline.scanner.rules.boundary_without_rejection import BoundaryWithoutRejection - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='ASSURED')\n" - "def validate(p):\n if not p:\n raise ValueError\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def validate(p):\n if not p:\n raise ValueError\n return p\n", + }, + ) # Sanity: the engine produces the shape that WOULD trip an unexempted PY-WL-101. from wardline.core.taints import TRUST_RANK + body = ctx.project_taints["m.validate"] declared = ctx.project_return_taints["m.validate"] actual = ctx.function_return_taints["m.validate"] - assert TRUST_RANK[actual] > TRUST_RANK[declared] # would-fire condition holds - assert TRUST_RANK[body] > TRUST_RANK[declared] # ...but it's a trust-raising transition + assert TRUST_RANK[actual] > TRUST_RANK[declared] # would-fire condition holds + assert TRUST_RANK[body] > TRUST_RANK[declared] # ...but it's a trust-raising transition # Exemption holds: no PY-WL-101, and 102 is satisfied by the raise guard. assert _run(ctx) == [] assert BoundaryWithoutRejection().check(ctx) == [] diff --git a/tests/unit/scanner/rules/test_vocabulary_shape_pin.py b/tests/unit/scanner/rules/test_vocabulary_shape_pin.py index fcde1c97..c7eee0bc 100644 --- a/tests/unit/scanner/rules/test_vocabulary_shape_pin.py +++ b/tests/unit/scanner/rules/test_vocabulary_shape_pin.py @@ -23,12 +23,15 @@ def _analyze(tmp_path: Path, files: dict[str, str]): def test_decorator_taint_shapes_are_distinct_and_stable(tmp_path) -> None: - ctx, _ = _analyze(tmp_path, { - "m.py": "from wardline.decorators import external_boundary, trust_boundary, trusted\n" - "@external_boundary\ndef eb(p):\n return p\n" - "@trust_boundary(to_level='ASSURED')\ndef tb(p):\n return p\n" - "@trusted(level='ASSURED')\ndef tr(p):\n return p\n", - }) + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import external_boundary, trust_boundary, trusted\n" + "@external_boundary\ndef eb(p):\n return p\n" + "@trust_boundary(to_level='ASSURED')\ndef tb(p):\n return p\n" + "@trusted(level='ASSURED')\ndef tr(p):\n return p\n", + }, + ) body = ctx.project_taints ret = ctx.project_return_taints # @external_boundary: body == return == EXTERNAL_RAW (raw-zone return -> PY-WL-101 gated) diff --git a/tests/unit/scanner/taint/test_call_taint_map.py b/tests/unit/scanner/taint/test_call_taint_map.py index df537f87..b3a4df60 100644 --- a/tests/unit/scanner/taint/test_call_taint_map.py +++ b/tests/unit/scanner/taint/test_call_taint_map.py @@ -15,7 +15,8 @@ def _aliases(src: str, module: str) -> dict[str, str]: def test_local_function_keyed_bare() -> None: aliases = _aliases("def a(): pass\ndef b(): pass\n", "m") tm = build_call_taint_map( - module_path="m", alias_map=aliases, + module_path="m", + alias_map=aliases, project_by_module={"m": {"a": T.MIXED_RAW, "b": T.UNKNOWN_RAW}}, ) assert tm["a"] == T.MIXED_RAW @@ -25,7 +26,8 @@ def test_local_function_keyed_bare() -> None: def test_from_import_project_function_keyed_bare() -> None: aliases = _aliases("from other import helper\n", "m") tm = build_call_taint_map( - module_path="m", alias_map=aliases, + module_path="m", + alias_map=aliases, project_by_module={"other": {"helper": T.EXTERNAL_RAW}}, ) assert tm["helper"] == T.EXTERNAL_RAW @@ -34,7 +36,8 @@ def test_from_import_project_function_keyed_bare() -> None: def test_dotted_module_project_call_keyed_dotted() -> None: aliases = _aliases("import other\n", "m") tm = build_call_taint_map( - module_path="m", alias_map=aliases, + module_path="m", + alias_map=aliases, project_by_module={"other": {"fn": T.MIXED_RAW}}, ) assert tm["other.fn"] == T.MIXED_RAW @@ -93,7 +96,8 @@ def test_project_function_takes_precedence_over_stdlib() -> None: # A project function shadowing a stdlib name keeps its refined taint. aliases = _aliases("import subprocess as sp\n", "m") tm = build_call_taint_map( - module_path="m", alias_map=aliases, + module_path="m", + alias_map=aliases, project_by_module={"m": {"sp": T.INTEGRAL}}, # local 'sp' bare function ) assert tm["sp"] == T.INTEGRAL # local bare entry untouched by stdlib dotted diff --git a/tests/unit/scanner/taint/test_callgraph.py b/tests/unit/scanner/taint/test_callgraph.py index c8bcc23f..291ff6e1 100644 --- a/tests/unit/scanner/taint/test_callgraph.py +++ b/tests/unit/scanner/taint/test_callgraph.py @@ -22,8 +22,11 @@ def test_local_bare_call_edge() -> None: tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) edges, resolved, unresolved = build_call_edges( - entities=entities, class_qualnames=classes, alias_map=aliases, - module_prefix="m", project_fqns=project_fqns, + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, ) assert edges["m.a"] == frozenset({"m.b"}) assert resolved["m.a"] == 1 @@ -36,25 +39,25 @@ def test_imported_call_edge() -> None: tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset({"m.a", "other.helper"}) edges, resolved, unresolved = build_call_edges( - entities=entities, class_qualnames=classes, alias_map=aliases, - module_prefix="m", project_fqns=project_fqns, + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, ) assert edges["m.a"] == frozenset({"other.helper"}) def test_self_method_edge() -> None: - src = ( - "class C:\n" - " def process(self):\n" - " return self.helper()\n" - " def helper(self):\n" - " return 1\n" - ) + src = "class C:\n def process(self):\n return self.helper()\n def helper(self):\n return 1\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) edges, resolved, unresolved = build_call_edges( - entities=entities, class_qualnames=classes, alias_map=aliases, - module_prefix="m", project_fqns=project_fqns, + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, ) assert edges["m.C.process"] == frozenset({"m.C.helper"}) assert resolved["m.C.process"] == 1 @@ -65,8 +68,11 @@ def test_unresolved_external_call_counted() -> None: tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset({"m.a"}) edges, resolved, unresolved = build_call_edges( - entities=entities, class_qualnames=classes, alias_map=aliases, - module_prefix="m", project_fqns=project_fqns, + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, ) assert edges["m.a"] == frozenset() assert unresolved["m.a"] == 1 @@ -78,26 +84,26 @@ def test_constructor_call_is_unresolved() -> None: tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) edges, resolved, unresolved = build_call_edges( - entities=entities, class_qualnames=classes, alias_map=aliases, - module_prefix="m", project_fqns=project_fqns, + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, ) assert edges["m.make"] == frozenset() assert unresolved["m.make"] == 1 def test_nested_def_calls_not_attributed_to_outer() -> None: - src = ( - "def outer():\n" - " def inner():\n" - " return b()\n" - " return inner()\n" - "def b():\n return 1\n" - ) + src = "def outer():\n def inner():\n return b()\n return inner()\ndef b():\n return 1\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) edges, resolved, unresolved = build_call_edges( - entities=entities, class_qualnames=classes, alias_map=aliases, - module_prefix="m", project_fqns=project_fqns, + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, ) # outer() calls inner() (a nested def, not a project entity) -> unresolved; # b() is called only inside inner's body, NOT attributed to outer. diff --git a/tests/unit/scanner/taint/test_decorator_provider.py b/tests/unit/scanner/taint/test_decorator_provider.py index 601abbd7..82cee000 100644 --- a/tests/unit/scanner/taint/test_decorator_provider.py +++ b/tests/unit/scanner/taint/test_decorator_provider.py @@ -22,77 +22,73 @@ def _seed(src: str, *, module: str = "m") -> dict[str, FunctionTaint | None]: def test_external_boundary_from_import() -> None: - out = _seed("from wardline.decorators import external_boundary\n" - "@external_boundary\ndef read(p):\n return p\n") + out = _seed("from wardline.decorators import external_boundary\n@external_boundary\ndef read(p):\n return p\n") assert out["m.read"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) def test_external_boundary_aliased_from_import() -> None: - out = _seed("from wardline.decorators import external_boundary as eb\n" - "@eb\ndef read(p):\n return p\n") + out = _seed("from wardline.decorators import external_boundary as eb\n@eb\ndef read(p):\n return p\n") assert out["m.read"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) def test_external_boundary_module_alias_attribute() -> None: - out = _seed("import wardline.decorators as wd\n" - "@wd.external_boundary\ndef read(p):\n return p\n") + out = _seed("import wardline.decorators as wd\n@wd.external_boundary\ndef read(p):\n return p\n") assert out["m.read"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) def test_external_boundary_plain_import_dotted() -> None: - out = _seed("import wardline.decorators\n" - "@wardline.decorators.external_boundary\ndef read(p):\n return p\n") + out = _seed("import wardline.decorators\n@wardline.decorators.external_boundary\ndef read(p):\n return p\n") assert out["m.read"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) def test_trust_boundary_to_level_string() -> None: - out = _seed("from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='ASSURED')\ndef v(x):\n return x\n") + out = _seed( + "from wardline.decorators import trust_boundary\n@trust_boundary(to_level='ASSURED')\ndef v(x):\n return x\n" + ) assert out["m.v"] == FunctionTaint(T.EXTERNAL_RAW, T.ASSURED) def test_trust_boundary_to_level_enum_attribute() -> None: - out = _seed("from wardline.decorators import trust_boundary\n" - "from wardline.core.taints import TaintState\n" - "@trust_boundary(to_level=TaintState.GUARDED)\ndef v(x):\n return x\n") + out = _seed( + "from wardline.decorators import trust_boundary\n" + "from wardline.core.taints import TaintState\n" + "@trust_boundary(to_level=TaintState.GUARDED)\ndef v(x):\n return x\n" + ) assert out["m.v"] == FunctionTaint(T.EXTERNAL_RAW, T.GUARDED) def test_trust_boundary_disallowed_level_is_no_opinion() -> None: # INTEGRAL is not a valid boundary target -> fail-closed (None). - out = _seed("from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='INTEGRAL')\ndef v(x):\n return x\n") + out = _seed( + "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='INTEGRAL')\ndef v(x):\n return x\n" + ) assert out["m.v"] is None def test_trust_boundary_bare_is_no_opinion() -> None: - out = _seed("from wardline.decorators import trust_boundary\n" - "@trust_boundary\ndef v(x):\n return x\n") + out = _seed("from wardline.decorators import trust_boundary\n@trust_boundary\ndef v(x):\n return x\n") assert out["m.v"] is None def test_trusted_bare_defaults_integral() -> None: - out = _seed("from wardline.decorators import trusted\n" - "@trusted\ndef f():\n return 1\n") + out = _seed("from wardline.decorators import trusted\n@trusted\ndef f():\n return 1\n") assert out["m.f"] == FunctionTaint(T.INTEGRAL, T.INTEGRAL) def test_trusted_level_assured() -> None: - out = _seed("from wardline.decorators import trusted\n" - "@trusted(level='ASSURED')\ndef f():\n return 1\n") + out = _seed("from wardline.decorators import trusted\n@trusted(level='ASSURED')\ndef f():\n return 1\n") assert out["m.f"] == FunctionTaint(T.ASSURED, T.ASSURED) def test_trusted_disallowed_level_is_no_opinion() -> None: - out = _seed("from wardline.decorators import trusted\n" - "@trusted(level='GUARDED')\ndef f():\n return 1\n") + out = _seed("from wardline.decorators import trusted\n@trusted(level='GUARDED')\ndef f():\n return 1\n") assert out["m.f"] is None def test_trusted_dynamic_level_is_no_opinion() -> None: # A non-literal level (a Name) cannot be read statically -> fail-closed. - out = _seed("from wardline.decorators import trusted\n" - "LV = 'ASSURED'\n@trusted(level=LV)\ndef f():\n return 1\n") + out = _seed("from wardline.decorators import trusted\nLV = 'ASSURED'\n@trusted(level=LV)\ndef f():\n return 1\n") assert out["m.f"] is None @@ -115,8 +111,10 @@ def test_coincidental_local_name_not_from_wardline_is_no_opinion() -> None: def test_conflicting_decorators_pick_least_trusted_return() -> None: # An authoring conflict: @trusted (INTEGRAL) + @external_boundary (EXTERNAL_RAW). # Fail-closed: the least-trusted return wins (never over-trust). - out = _seed("from wardline.decorators import external_boundary, trusted\n" - "@trusted\n@external_boundary\ndef f(p):\n return p\n") + out = _seed( + "from wardline.decorators import external_boundary, trusted\n" + "@trusted\n@external_boundary\ndef f(p):\n return p\n" + ) assert out["m.f"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) @@ -125,12 +123,16 @@ def test_conflicting_decorators_least_trusted_per_field_order_independent() -> N # has body ASSURED, @trust_boundary(to_level=ASSURED) has body EXTERNAL_RAW. # The body must resolve to the least-trusted (EXTERNAL_RAW) regardless of # decorator source order — never an order-dependent body under-taint. - src_a = ("from wardline.decorators import trusted, trust_boundary\n" - "@trusted(level='ASSURED')\n@trust_boundary(to_level='ASSURED')\n" - "def f(x):\n return x\n") - src_b = ("from wardline.decorators import trusted, trust_boundary\n" - "@trust_boundary(to_level='ASSURED')\n@trusted(level='ASSURED')\n" - "def f(x):\n return x\n") + src_a = ( + "from wardline.decorators import trusted, trust_boundary\n" + "@trusted(level='ASSURED')\n@trust_boundary(to_level='ASSURED')\n" + "def f(x):\n return x\n" + ) + src_b = ( + "from wardline.decorators import trusted, trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n@trusted(level='ASSURED')\n" + "def f(x):\n return x\n" + ) expected = FunctionTaint(T.EXTERNAL_RAW, T.ASSURED) assert _seed(src_a)["m.f"] == expected assert _seed(src_b)["m.f"] == expected @@ -139,22 +141,22 @@ def test_conflicting_decorators_least_trusted_per_field_order_independent() -> N def test_non_taintstate_attribute_level_is_no_opinion() -> None: # A dynamic value via a non-TaintState attribute (cfg.GUARDED) must NOT be # read as a level — its runtime value is unknown -> fail-closed. - out = _seed("import cfg\nfrom wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level=cfg.GUARDED)\ndef v(x):\n return x\n") + out = _seed( + "import cfg\nfrom wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level=cfg.GUARDED)\ndef v(x):\n return x\n" + ) assert out["m.v"] is None def test_from_wardline_import_decorators_attribute_form() -> None: - out = _seed("from wardline import decorators\n" - "@decorators.external_boundary\ndef read(p):\n return p\n") + out = _seed("from wardline import decorators\n@decorators.external_boundary\ndef read(p):\n return p\n") assert out["m.read"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) def test_positional_level_arg_is_no_opinion() -> None: # The real decorators are keyword-only; a positional form is malformed source # and must read as fail-closed (no opinion), never a silent under-read. - out = _seed("from wardline.decorators import trust_boundary\n" - "@trust_boundary('ASSURED')\ndef v(x):\n return x\n") + out = _seed("from wardline.decorators import trust_boundary\n@trust_boundary('ASSURED')\ndef v(x):\n return x\n") assert out["m.v"] is None diff --git a/tests/unit/scanner/taint/test_function_level.py b/tests/unit/scanner/taint/test_function_level.py index 8aca7b0e..7f1ffa76 100644 --- a/tests/unit/scanner/taint/test_function_level.py +++ b/tests/unit/scanner/taint/test_function_level.py @@ -19,9 +19,7 @@ def _entities(src: str) -> list[Entity]: def test_default_provider_seeds_all_unknown_raw() -> None: entities = _entities("def a():\n pass\ndef b():\n pass\n") - seeds = seed_function_taints( - entities, ctx=SeedContext(module="demo"), provider=DefaultTaintSourceProvider() - ) + seeds = seed_function_taints(entities, ctx=SeedContext(module="demo"), provider=DefaultTaintSourceProvider()) assert set(seeds) == {"demo.a", "demo.b"} for seed in seeds.values(): assert seed.body_taint == TaintState.UNKNOWN_RAW @@ -34,17 +32,13 @@ class _StubProvider: def taint_for(self, entity: Entity, ctx: SeedContext) -> FunctionTaint | None: if entity.qualname == "demo.a": - return FunctionTaint( - body_taint=TaintState.EXTERNAL_RAW, return_taint=TaintState.GUARDED - ) + return FunctionTaint(body_taint=TaintState.EXTERNAL_RAW, return_taint=TaintState.GUARDED) return None def test_provider_opinion_used_else_fallback() -> None: entities = _entities("def a():\n pass\ndef b():\n pass\n") - seeds = seed_function_taints( - entities, ctx=SeedContext(module="demo"), provider=_StubProvider() - ) + seeds = seed_function_taints(entities, ctx=SeedContext(module="demo"), provider=_StubProvider()) assert seeds["demo.a"] == FunctionSeed( qualname="demo.a", body_taint=TaintState.EXTERNAL_RAW, @@ -56,16 +50,12 @@ def test_provider_opinion_used_else_fallback() -> None: def test_empty_entity_list() -> None: - seeds = seed_function_taints( - [], ctx=SeedContext(module="demo"), provider=DefaultTaintSourceProvider() - ) + seeds = seed_function_taints([], ctx=SeedContext(module="demo"), provider=DefaultTaintSourceProvider()) assert seeds == {} def test_methods_and_closures_all_seeded() -> None: src = "class C:\n def m(self):\n def inner():\n pass\n" entities = _entities(src) - seeds = seed_function_taints( - entities, ctx=SeedContext(module="demo"), provider=DefaultTaintSourceProvider() - ) + seeds = seed_function_taints(entities, ctx=SeedContext(module="demo"), provider=DefaultTaintSourceProvider()) assert set(seeds) == {"demo.C.m", "demo.C.m..inner"} diff --git a/tests/unit/scanner/taint/test_minimum_scope.py b/tests/unit/scanner/taint/test_minimum_scope.py index da4da707..6c431661 100644 --- a/tests/unit/scanner/taint/test_minimum_scope.py +++ b/tests/unit/scanner/taint/test_minimum_scope.py @@ -119,12 +119,16 @@ def test_three_hop_is_bounded_out() -> None: "m.hop2": frozenset({"m.raw"}), }, seed_taints={ - "m.handler": T.ASSURED, "m.hop1": T.ASSURED, - "m.hop2": T.ASSURED, "m.raw": T.EXTERNAL_RAW, + "m.handler": T.ASSURED, + "m.hop1": T.ASSURED, + "m.hop2": T.ASSURED, + "m.raw": T.EXTERNAL_RAW, }, seed_sources={ - "m.handler": "default", "m.hop1": "default", - "m.hop2": "default", "m.raw": "provider", + "m.handler": "default", + "m.hop1": "default", + "m.hop2": "default", + "m.raw": "provider", }, return_taints={"m.raw": T.EXTERNAL_RAW}, unresolved_counts={}, diff --git a/tests/unit/scanner/taint/test_module_summariser.py b/tests/unit/scanner/taint/test_module_summariser.py index 3ffa0277..a22a0031 100644 --- a/tests/unit/scanner/taint/test_module_summariser.py +++ b/tests/unit/scanner/taint/test_module_summariser.py @@ -16,8 +16,12 @@ def test_summaries_map_provider_seed_to_anchored() -> None: "m.b": _seed("m.b", T.UNKNOWN_RAW, T.UNKNOWN_RAW, "default"), } summaries = summarise_module( - module_path="m", seeds=seeds, unresolved_counts={"m.a": 0, "m.b": 2}, - source_bytes=b"x\n", resolver_version="sp1d", provider_fingerprint="default-v1", + module_path="m", + seeds=seeds, + unresolved_counts={"m.a": 0, "m.b": 2}, + source_bytes=b"x\n", + resolver_version="sp1d", + provider_fingerprint="default-v1", ) by_fqn = {s.fqn: s for s in summaries} assert by_fqn["m.a"].taint_source == "anchored" @@ -33,8 +37,12 @@ def test_all_summaries_in_module_share_cache_key() -> None: "m.b": _seed("m.b", T.UNKNOWN_RAW, T.UNKNOWN_RAW, "default"), } summaries = summarise_module( - module_path="m", seeds=seeds, unresolved_counts={"m.a": 0, "m.b": 0}, - source_bytes=b"x\n", resolver_version="sp1d", provider_fingerprint="default-v1", + module_path="m", + seeds=seeds, + unresolved_counts={"m.a": 0, "m.b": 0}, + source_bytes=b"x\n", + resolver_version="sp1d", + provider_fingerprint="default-v1", ) keys = {s.cache_key for s in summaries} assert len(keys) == 1 # cache_key is module-granular @@ -43,8 +51,12 @@ def test_all_summaries_in_module_share_cache_key() -> None: def test_missing_unresolved_count_defaults_zero() -> None: seeds = {"m.a": _seed("m.a", T.UNKNOWN_RAW, T.UNKNOWN_RAW, "default")} summaries = summarise_module( - module_path="m", seeds=seeds, unresolved_counts={}, - source_bytes=b"x\n", resolver_version="sp1d", provider_fingerprint="default-v1", + module_path="m", + seeds=seeds, + unresolved_counts={}, + source_bytes=b"x\n", + resolver_version="sp1d", + provider_fingerprint="default-v1", ) assert summaries[0].unresolved_calls == 0 @@ -54,8 +66,10 @@ def test_identical_source_distinct_modules_get_distinct_keys() -> None: seeds_a = {"a.f": _seed("a.f", T.UNKNOWN_RAW, T.UNKNOWN_RAW, "default")} seeds_b = {"b.f": _seed("b.f", T.UNKNOWN_RAW, T.UNKNOWN_RAW, "default")} common = dict( - unresolved_counts={}, source_bytes=b"def f(): pass\n", - resolver_version="sp1d", provider_fingerprint="default-v1", + unresolved_counts={}, + source_bytes=b"def f(): pass\n", + resolver_version="sp1d", + provider_fingerprint="default-v1", ) key_a = summarise_module(module_path="a", seeds=seeds_a, **common)[0].cache_key key_b = summarise_module(module_path="b", seeds=seeds_b, **common)[0].cache_key diff --git a/tests/unit/scanner/taint/test_project_resolver.py b/tests/unit/scanner/taint/test_project_resolver.py index 41f0a068..735bba74 100644 --- a/tests/unit/scanner/taint/test_project_resolver.py +++ b/tests/unit/scanner/taint/test_project_resolver.py @@ -116,12 +116,14 @@ def test_cache_and_dirty_must_be_supplied_together() -> None: inputs = _inputs(provider) with pytest.raises(ValueError, match="together"): resolve_project_taints( - modules=inputs, provider_fingerprint=provider.fingerprint(), + modules=inputs, + provider_fingerprint=provider.fingerprint(), summary_cache=SummaryCache(), # dirty_modules omitted ) with pytest.raises(ValueError, match="together"): resolve_project_taints( - modules=inputs, provider_fingerprint=provider.fingerprint(), + modules=inputs, + provider_fingerprint=provider.fingerprint(), dirty_modules=frozenset(), # summary_cache omitted ) @@ -135,12 +137,16 @@ def test_warm_run_equals_cold_run() -> None: cache = SummaryCache() run1 = resolve_project_taints( - modules=inputs, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset(), + modules=inputs, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset(), ) run2 = resolve_project_taints( - modules=inputs, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset(), + modules=inputs, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset(), ) # cached ≡ cold, byte-for-byte on taint and provenance @@ -165,13 +171,17 @@ def test_dirty_frontier_recompute_still_equals_cold() -> None: cache = SummaryCache() resolve_project_taints( - modules=inputs, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset({"pkg.io_layer"}), + modules=inputs, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset({"pkg.io_layer"}), ) # Mark the leaf's module dirty; its callers are in the frontier. warm = resolve_project_taints( - modules=inputs, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset({"pkg.io_layer"}), + modules=inputs, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset({"pkg.io_layer"}), ) assert dict(warm.taint_map) == dict(cold.taint_map) @@ -189,12 +199,16 @@ def test_identical_source_modules_do_not_collide_in_cache() -> None: cold = resolve_project_taints(modules=inputs, provider_fingerprint=fp) cache = SummaryCache() resolve_project_taints( - modules=inputs, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset(), + modules=inputs, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset(), ) warm = resolve_project_taints( - modules=inputs, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset(), + modules=inputs, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset(), ) assert set(cold.taint_map) == {"pkg.a.f", "pkg.b.f"} assert dict(warm.taint_map) == dict(cold.taint_map) @@ -213,12 +227,7 @@ def test_resolver_exposes_effective_return_taint_map() -> None: from wardline.scanner.taint.function_level import seed_function_taints from wardline.scanner.taint.provider import FunctionTaint, SeedContext - src = ( - "def validate(p):\n" - " if not p:\n raise ValueError\n" - " return p\n" - "def plain(p):\n return p\n" - ) + src = "def validate(p):\n if not p:\n raise ValueError\n return p\ndef plain(p):\n return p\n" tree = ast.parse(src) module = "m" entities = tuple(discover_file_entities(tree, module=module, path="m.py")) @@ -235,9 +244,7 @@ def fingerprint(self) -> str: return "test-effret-v1" provider = _Provider() - seeds = seed_function_taints( - entities, ctx=SeedContext(module=module, alias_map=alias_map), provider=provider - ) + seeds = seed_function_taints(entities, ctx=SeedContext(module=module, alias_map=alias_map), provider=provider) modules = [ ModuleInput( module_path=module, @@ -250,8 +257,8 @@ def fingerprint(self) -> str: ] result = resolve_project_taints(modules=modules, provider_fingerprint=provider.fingerprint()) - assert result.taint_map["m.validate"] == T.EXTERNAL_RAW # body unchanged - assert result.return_taint_map["m.validate"] == T.ASSURED # declared return + assert result.taint_map["m.validate"] == T.EXTERNAL_RAW # body unchanged + assert result.return_taint_map["m.validate"] == T.ASSURED # declared return # non-anchored: effective return == refined body taint assert result.return_taint_map["m.plain"] == result.taint_map["m.plain"] @@ -265,8 +272,10 @@ def test_cache_miss_on_changed_source_recomputes() -> None: inputs_v1 = _inputs(provider) resolve_project_taints( - modules=inputs_v1, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset(), + modules=inputs_v1, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset(), ) len_after_v1 = len(cache) @@ -278,8 +287,10 @@ def test_cache_miss_on_changed_source_recomputes() -> None: _module_input("pkg.handler", _HANDLER, provider), ] warm = resolve_project_taints( - modules=inputs_v2, provider_fingerprint=fp, - summary_cache=cache, dirty_modules=frozenset(), + modules=inputs_v2, + provider_fingerprint=fp, + summary_cache=cache, + dirty_modules=frozenset(), ) cold_v2 = resolve_project_taints(modules=inputs_v2, provider_fingerprint=fp) assert dict(warm.taint_map) == dict(cold_v2.taint_map) diff --git a/tests/unit/scanner/taint/test_propagation.py b/tests/unit/scanner/taint/test_propagation.py index e86f0ffe..c8cdbbaf 100644 --- a/tests/unit/scanner/taint/test_propagation.py +++ b/tests/unit/scanner/taint/test_propagation.py @@ -18,8 +18,11 @@ def _run(edges, taint_map, sources, *, return_map=None, unresolved=None): unresolved = unresolved or {k: 0 for k in taint_map} return_map = return_map if return_map is not None else dict(taint_map) return propagate_callgraph_taints( - edges=edges, taint_map=taint_map, taint_sources=sources, - resolved_counts=resolved, unresolved_counts=unresolved, + edges=edges, + taint_map=taint_map, + taint_sources=sources, + resolved_counts=resolved, + unresolved_counts=unresolved, return_taint_map=return_map, ) @@ -29,7 +32,10 @@ def test_compute_sccs_reverse_topo_order() -> None: sccs = compute_sccs(g) # leaves first; the cycle {D,E} is one component assert {frozenset(s) for s in sccs} == { - frozenset({"C"}), frozenset({"B"}), frozenset({"A"}), frozenset({"D", "E"}), + frozenset({"C"}), + frozenset({"B"}), + frozenset({"A"}), + frozenset({"D", "E"}), } assert sccs.index({"C"}) < sccs.index({"B"}) < sccs.index({"A"}) @@ -165,7 +171,7 @@ def test_fallback_caller_clean_callees_floor_holds_not_mixed() -> None: def test_long_chain_converges_without_bound_diagnostic() -> None: n = 20 - edges = {f"f{i}": {f"f{i+1}"} for i in range(n)} + edges = {f"f{i}": {f"f{i + 1}"} for i in range(n)} edges[f"f{n}"] = set() tm = {f"f{i}": (T.MIXED_RAW if i == n else T.INTEGRAL) for i in range(n + 1)} src = {f"f{i}": ("anchored" if i == n else "module_default") for i in range(n + 1)} @@ -185,8 +191,12 @@ def test_anchored_function_provenance_source() -> None: def test_empty_taint_map_returns_empty() -> None: refined, prov, diags, it = propagate_callgraph_taints( - edges={}, taint_map={}, taint_sources={}, - resolved_counts={}, unresolved_counts={}, return_taint_map={}, + edges={}, + taint_map={}, + taint_sources={}, + resolved_counts={}, + unresolved_counts={}, + return_taint_map={}, ) assert refined == {} and prov == {} and diags == [] and it == {} @@ -225,8 +235,11 @@ def buggy_round(**_kwargs): tm = {"A": T.UNKNOWN_RAW, "B": T.GUARDED} src = {"A": "fallback", "B": "anchored"} refined, _prov, diags, _it = propagate_callgraph_taints( - edges=edges, taint_map=tm, taint_sources=src, - resolved_counts={"A": 1, "B": 0}, unresolved_counts={"A": 0, "B": 0}, + edges=edges, + taint_map=tm, + taint_sources=src, + resolved_counts={"A": 1, "B": 0}, + unresolved_counts={"A": 0, "B": 0}, return_taint_map={"A": T.UNKNOWN_RAW, "B": T.GUARDED}, ) assert any(code == DIAG_MONOTONICITY_VIOLATION for code, _ in diags) @@ -247,8 +260,11 @@ def buggy_round(**kwargs): src = {"A": "fallback", "B": "anchored"} with caplog.at_level(logging.ERROR, logger=propagation.__name__): refined, prov, _diags, _it = propagate_callgraph_taints( - edges=edges, taint_map=tm, taint_sources=src, - resolved_counts={"A": 1, "B": 0}, unresolved_counts={"A": 0, "B": 0}, + edges=edges, + taint_map=tm, + taint_sources=src, + resolved_counts={"A": 1, "B": 0}, + unresolved_counts={"A": 0, "B": 0}, return_taint_map={"A": T.UNKNOWN_RAW, "B": T.GUARDED}, ) assert refined == tm # bailed to the unrefined L1 map @@ -270,8 +286,11 @@ def buggy_round(**kwargs): src = {"A": "module_default", "B": "anchored"} with caplog.at_level(logging.ERROR, logger=propagation.__name__): refined, prov, _diags, _it = propagate_callgraph_taints( - edges=edges, taint_map=tm, taint_sources=src, - resolved_counts={"A": 1, "B": 0}, unresolved_counts={"A": 0, "B": 0}, + edges=edges, + taint_map=tm, + taint_sources=src, + resolved_counts={"A": 1, "B": 0}, + unresolved_counts={"A": 0, "B": 0}, return_taint_map={"A": T.UNKNOWN_RAW, "B": T.GUARDED}, ) assert refined == tm # bailed to the unrefined L1 map diff --git a/tests/unit/scanner/taint/test_provider.py b/tests/unit/scanner/taint/test_provider.py index 967b844c..6c514c10 100644 --- a/tests/unit/scanner/taint/test_provider.py +++ b/tests/unit/scanner/taint/test_provider.py @@ -31,9 +31,7 @@ def test_default_provider_satisfies_protocol() -> None: def test_function_taint_is_frozen() -> None: - taint = FunctionTaint( - body_taint=TaintState.EXTERNAL_RAW, return_taint=TaintState.GUARDED - ) + taint = FunctionTaint(body_taint=TaintState.EXTERNAL_RAW, return_taint=TaintState.GUARDED) assert taint.body_taint == TaintState.EXTERNAL_RAW assert taint.return_taint == TaintState.GUARDED with pytest.raises(dataclasses.FrozenInstanceError): diff --git a/tests/unit/scanner/taint/test_stdlib_taint.py b/tests/unit/scanner/taint/test_stdlib_taint.py index 49bfb2fc..7ddf57db 100644 --- a/tests/unit/scanner/taint/test_stdlib_taint.py +++ b/tests/unit/scanner/taint/test_stdlib_taint.py @@ -54,9 +54,7 @@ def test_non_canonical_taint_token_raises() -> None: _build_table( { "version": STDLIB_TAINT_VERSION, - "entries": [ - {"package": "p", "function": "f", "returns_taint": "BOGUS"} - ], + "entries": [{"package": "p", "function": "f", "returns_taint": "BOGUS"}], } ) @@ -113,9 +111,7 @@ def test_empty_entries_is_valid_empty_table() -> None: # the pipeline (reachable-set invariant; taint-combination audit F5). ── -@pytest.mark.parametrize( - "state", ["MIXED_RAW", "UNKNOWN_GUARDED", "UNKNOWN_ASSURED", "INTEGRAL"] -) +@pytest.mark.parametrize("state", ["MIXED_RAW", "UNKNOWN_GUARDED", "UNKNOWN_ASSURED", "INTEGRAL"]) def test_illegal_stdlib_return_tier_raises(state: str) -> None: # MIXED_RAW etc. ARE canonical TaintState strings, so they pass TaintState(), # but they are not legal stdlib return tiers — the guard must reject them. @@ -123,23 +119,17 @@ def test_illegal_stdlib_return_tier_raises(state: str) -> None: _build_table( { "version": STDLIB_TAINT_VERSION, - "entries": [ - {"package": "p", "function": "f", "returns_taint": state, "rationale": "x"} - ], + "entries": [{"package": "p", "function": "f", "returns_taint": state, "rationale": "x"}], } ) -@pytest.mark.parametrize( - "state", ["ASSURED", "GUARDED", "EXTERNAL_RAW", "UNKNOWN_RAW"] -) +@pytest.mark.parametrize("state", ["ASSURED", "GUARDED", "EXTERNAL_RAW", "UNKNOWN_RAW"]) def test_legal_stdlib_return_tiers_accepted(state: str) -> None: table = _build_table( { "version": STDLIB_TAINT_VERSION, - "entries": [ - {"package": "p", "function": "f", "returns_taint": state, "rationale": "x"} - ], + "entries": [{"package": "p", "function": "f", "returns_taint": state, "rationale": "x"}], } ) assert table[("p", "f")].taint == TaintState(state) diff --git a/tests/unit/scanner/taint/test_summary.py b/tests/unit/scanner/taint/test_summary.py index 1b0f71cf..fd7b6948 100644 --- a/tests/unit/scanner/taint/test_summary.py +++ b/tests/unit/scanner/taint/test_summary.py @@ -48,10 +48,16 @@ def test_cache_key_rejects_crlf_source() -> None: def test_cache_key_length_prefixed_no_collision() -> None: # ("ab","c") vs ("a","bc") must not collide across adjacent fields. assert compute_cache_key( - module_path="m", source_bytes=b"ab", schema_version=1, resolver_version="c", + module_path="m", + source_bytes=b"ab", + schema_version=1, + resolver_version="c", provider_fingerprint="x", ) != compute_cache_key( - module_path="m", source_bytes=b"a", schema_version=1, resolver_version="bc", + module_path="m", + source_bytes=b"a", + schema_version=1, + resolver_version="bc", provider_fingerprint="x", ) @@ -59,26 +65,38 @@ def test_cache_key_length_prefixed_no_collision() -> None: def test_summary_rejects_wrong_schema_version() -> None: with pytest.raises(ValueError, match="schema_version"): FunctionSummary( - fqn="m.f", body_taint=T.UNKNOWN_RAW, return_taint=T.UNKNOWN_RAW, - taint_source="fallback", unresolved_calls=0, - schema_version=SUMMARY_SCHEMA_VERSION + 99, cache_key="x", + fqn="m.f", + body_taint=T.UNKNOWN_RAW, + return_taint=T.UNKNOWN_RAW, + taint_source="fallback", + unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION + 99, + cache_key="x", ) def test_summary_rejects_negative_unresolved() -> None: with pytest.raises(ValueError, match="unresolved_calls"): FunctionSummary( - fqn="m.f", body_taint=T.UNKNOWN_RAW, return_taint=T.UNKNOWN_RAW, - taint_source="fallback", unresolved_calls=-1, - schema_version=SUMMARY_SCHEMA_VERSION, cache_key="x", + fqn="m.f", + body_taint=T.UNKNOWN_RAW, + return_taint=T.UNKNOWN_RAW, + taint_source="fallback", + unresolved_calls=-1, + schema_version=SUMMARY_SCHEMA_VERSION, + cache_key="x", ) def test_summary_is_frozen() -> None: s = FunctionSummary( - fqn="m.f", body_taint=T.UNKNOWN_RAW, return_taint=T.UNKNOWN_RAW, - taint_source="fallback", unresolved_calls=0, - schema_version=SUMMARY_SCHEMA_VERSION, cache_key="x", + fqn="m.f", + body_taint=T.UNKNOWN_RAW, + return_taint=T.UNKNOWN_RAW, + taint_source="fallback", + unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, + cache_key="x", ) with pytest.raises((AttributeError, TypeError)): s.fqn = "m.g" # type: ignore[misc] diff --git a/tests/unit/scanner/taint/test_summary_cache.py b/tests/unit/scanner/taint/test_summary_cache.py index dce91978..5bf0972f 100644 --- a/tests/unit/scanner/taint/test_summary_cache.py +++ b/tests/unit/scanner/taint/test_summary_cache.py @@ -16,8 +16,13 @@ def _summary(fqn: str, *, schema: int = SUMMARY_SCHEMA_VERSION, key: str = _KEY) -> FunctionSummary: return FunctionSummary( - fqn=fqn, body_taint=T.UNKNOWN_RAW, return_taint=T.UNKNOWN_RAW, - taint_source="fallback", unresolved_calls=0, schema_version=schema, cache_key=key, + fqn=fqn, + body_taint=T.UNKNOWN_RAW, + return_taint=T.UNKNOWN_RAW, + taint_source="fallback", + unresolved_calls=0, + schema_version=schema, + cache_key=key, ) @@ -42,8 +47,8 @@ def test_hit_rate_zero_when_no_activity() -> None: def test_hit_rate_fraction() -> None: c = SummaryCache() c.put(_KEY, (_summary("m.a"),)) - c.get(_KEY) # hit - c.get(_KEY2) # miss + c.get(_KEY) # hit + c.get(_KEY2) # miss assert c.hit_rate() == 0.5 @@ -168,9 +173,13 @@ def test_deserialise_integral_roundtrips() -> None: # cache MUST round-trip INTEGRAL body/return taint. Rejecting it here would # silently break caching of trusted functions. s = FunctionSummary( - fqn="m.f", body_taint=T.INTEGRAL, return_taint=T.INTEGRAL, - taint_source="anchored", unresolved_calls=0, - schema_version=SUMMARY_SCHEMA_VERSION, cache_key=_KEY, + fqn="m.f", + body_taint=T.INTEGRAL, + return_taint=T.INTEGRAL, + taint_source="anchored", + unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, + cache_key=_KEY, ) out = _deserialise_summary(_serialise_summary(s)) assert out == s @@ -182,9 +191,13 @@ def test_integral_survives_full_save_load_cycle(tmp_path) -> None: # but a legitimate INTEGRAL summary must survive the disk round-trip. c = SummaryCache(cache_dir=tmp_path) s = FunctionSummary( - fqn="m.f", body_taint=T.INTEGRAL, return_taint=T.INTEGRAL, - taint_source="anchored", unresolved_calls=0, - schema_version=SUMMARY_SCHEMA_VERSION, cache_key=_KEY, + fqn="m.f", + body_taint=T.INTEGRAL, + return_taint=T.INTEGRAL, + taint_source="anchored", + unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, + cache_key=_KEY, ) c.put(_KEY, (s,)) c.save() @@ -198,9 +211,7 @@ def test_load_drops_poisoned_trio_cache_file(tmp_path, caplog) -> None: # dropped (cold-cache fallback), not injected — load() catches the ValueError. import json - (tmp_path / f"{_KEY}.json").write_text( - json.dumps([_summary_dict("MIXED_RAW", "MIXED_RAW")]), encoding="utf-8" - ) + (tmp_path / f"{_KEY}.json").write_text(json.dumps([_summary_dict("MIXED_RAW", "MIXED_RAW")]), encoding="utf-8") c = SummaryCache(cache_dir=tmp_path) c.load() # must not raise assert len(c) == 0 diff --git a/tests/unit/scanner/taint/test_variable_level.py b/tests/unit/scanner/taint/test_variable_level.py index 672e5391..8550346e 100644 --- a/tests/unit/scanner/taint/test_variable_level.py +++ b/tests/unit/scanner/taint/test_variable_level.py @@ -66,14 +66,7 @@ def test_if_without_else_merges_with_pre_state() -> None: def test_try_except_merges_branches() -> None: - src = ( - "def f():\n" - " x = unknown\n" - " try:\n" - " x = 42\n" - " except Exception:\n" - " x = 7\n" - ) + src = "def f():\n x = unknown\n try:\n x = 42\n except Exception:\n x = 7\n" # try→INTEGRAL, handler→INTEGRAL → join = INTEGRAL assert _vt(src, function_taint=T.EXTERNAL_RAW)["x"] == T.INTEGRAL @@ -281,12 +274,14 @@ def test_match_capture_binds_subject_taint() -> None: def test_match_sequence_and_class_captures_bind_subject_taint() -> None: seq = _vt( "def f(p):\n match tainted():\n case [a, b]:\n z = a\n", - function_taint=T.INTEGRAL, taint_map={"tainted": T.EXTERNAL_RAW}, + function_taint=T.INTEGRAL, + taint_map={"tainted": T.EXTERNAL_RAW}, ) assert seq["z"] == T.EXTERNAL_RAW cls = _vt( "def f(p):\n match tainted():\n case Point(x=px):\n z = px\n", - function_taint=T.INTEGRAL, taint_map={"tainted": T.EXTERNAL_RAW}, + function_taint=T.INTEGRAL, + taint_map={"tainted": T.EXTERNAL_RAW}, ) assert cls["z"] == T.EXTERNAL_RAW @@ -329,17 +324,17 @@ def targets(pattern_src: str) -> set[str]: m = ast.parse(f"match x:\n case {pattern_src}:\n pass\n").body[0] return _collect_pattern_targets(m.cases[0].pattern) # type: ignore[attr-defined] - assert targets("1") == set() # MatchValue — no binding - assert targets("_") == set() # wildcard — no binding - assert targets("y") == {"y"} # MatchAs capture - assert targets("[a, b]") == {"a", "b"} # MatchSequence - assert targets("[a, *rest]") == {"a", "rest"} # MatchStar - assert targets("Point(x=px, y=py)") == {"px", "py"} # MatchClass kwd patterns - assert targets("Point(px, py)") == {"px", "py"} # MatchClass positional + assert targets("1") == set() # MatchValue — no binding + assert targets("_") == set() # wildcard — no binding + assert targets("y") == {"y"} # MatchAs capture + assert targets("[a, b]") == {"a", "b"} # MatchSequence + assert targets("[a, *rest]") == {"a", "rest"} # MatchStar + assert targets("Point(x=px, y=py)") == {"px", "py"} # MatchClass kwd patterns + assert targets("Point(px, py)") == {"px", "py"} # MatchClass positional assert targets("{'k': v, **others}") == {"v", "others"} # MatchMapping + rest - assert targets("Point() as whole") == {"whole"} # MatchAs with sub-pattern - assert targets("[a] | (b)") == {"a", "b"} # MatchOr — union of alternatives - assert targets("1 | 2") == set() # MatchOr of values — no binding + assert targets("Point() as whole") == {"whole"} # MatchAs with sub-pattern + assert targets("[a] | (b)") == {"a", "b"} # MatchOr — union of alternatives + assert targets("1 | 2") == set() # MatchOr of values — no binding def test_compute_return_taint_reaches_match_and_except_returns() -> None: @@ -396,10 +391,7 @@ def rc(src: str) -> str | None: # raw taint) and `return read_raw(p)` land at EXTERNAL_RAW, but the first is a # non-call. The loop must skip the worst-tier non-call to reach the worst-tier # direct call — so the callee is `read_raw`, not None. - assert ( - rc("def f(p):\n x = read_raw(p)\n if p:\n return x\n return read_raw(p)\n") - == "read_raw" - ) + assert rc("def f(p):\n x = read_raw(p)\n if p:\n return x\n return read_raw(p)\n") == "read_raw" # the worst path is the validated (trusted) call, not the raw one it wraps; the # top-level direct call of the least-trusted return is `validate`. assert rc("def f(p):\n return validate(read_raw(p))\n") == "validate" @@ -438,7 +430,8 @@ def test_fstring_with_literal_text_and_validated_stays_clean() -> None: # positive; taint_join would wrongly yield MIXED_RAW here.) out = _vt( "def f(p):\n x = f'x={validate(p)}'\n", - function_taint=T.ASSURED, taint_map={"validate": T.ASSURED}, + function_taint=T.ASSURED, + taint_map={"validate": T.ASSURED}, ) assert out["x"] == T.ASSURED @@ -455,7 +448,8 @@ def test_str_and_format_call_fall_through_to_arg_walrus_but_carry_function_taint # not str() — but we still assert the arg walrus is resolved for side effects. out = _vt( "def f(p):\n x = str(y := read_raw(p))\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["y"] == T.UNKNOWN_RAW # walrus side-effect captured @@ -475,7 +469,8 @@ def test_subscript_resolves_slice_for_walrus() -> None: # d[(i := read_raw(p))] — the slice walrus must bind. out = _vt( "def f(p, d):\n x = d[(i := read_raw(p))]\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["i"] == T.UNKNOWN_RAW @@ -484,7 +479,8 @@ def test_attribute_read_carries_object_taint() -> None: # o is UNKNOWN_RAW; o.x carries the object's taint. out = _vt( "def f(p):\n o = read_raw(p)\n x = o.attr\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -492,7 +488,8 @@ def test_attribute_read_carries_object_taint() -> None: def test_await_unwraps_inner_call() -> None: out = _vt( "async def f(p):\n r = await read_raw(p)\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["r"] == T.UNKNOWN_RAW @@ -512,7 +509,8 @@ def test_boolop_and_with_raw_value() -> None: def test_boolop_all_raw_stays_raw() -> None: out = _vt( "def f(p):\n x = read_raw(p) or read_raw(p)\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -521,7 +519,8 @@ def test_listcomp_carries_element_taint() -> None: # [x for x in [read_raw(p)]] — iter is UNKNOWN_RAW, x binds UNKNOWN_RAW, elt is x. out = _vt( "def f(p):\n x = [y for y in [read_raw(p)]]\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -529,7 +528,8 @@ def test_listcomp_carries_element_taint() -> None: def test_setcomp_carries_element_taint() -> None: out = _vt( "def f(p):\n x = {y for y in [read_raw(p)]}\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -537,7 +537,8 @@ def test_setcomp_carries_element_taint() -> None: def test_genexp_carries_element_taint() -> None: out = _vt( "def f(p):\n x = (y for y in [read_raw(p)])\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -547,7 +548,8 @@ def test_dictcomp_combines_key_and_value_taint() -> None: # = UNKNOWN_RAW (raw value propagates at its precise rank). out = _vt( "def f(p):\n x = {k: read_raw(p) for k in range(1)}\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -558,7 +560,8 @@ def test_multi_generator_comprehension_chains_taint() -> None: # scope, or gen2's iter resolves to function_taint (trusted seed) → launder. out = _vt( "def f(p):\n x = [y for row in [read_raw(p)] for y in row]\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -567,7 +570,8 @@ def test_comprehension_walrus_leaks_to_enclosing_scope() -> None: # PEP 572: a walrus inside a comprehension binds the ENCLOSING scope. out = _vt( "def f(p):\n x = [(w := read_raw(p)) for _ in range(1)]\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["w"] == T.UNKNOWN_RAW @@ -579,7 +583,8 @@ def test_subscript_write_taints_base_container() -> None: # d[k] = read_raw(p); then read d back — d must carry the contaminated taint. out = _vt( "def f(p):\n d = {}\n d['k'] = read_raw(p)\n x = d\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) # d was INTEGRAL ({}); least_trusted(INTEGRAL, UNKNOWN_RAW) = UNKNOWN_RAW — raw # write still contaminates (fires), at its precise rank rather than MIXED_RAW. @@ -591,7 +596,8 @@ def test_attribute_write_taints_base_object() -> None: # o.x = read_raw(p); return o.x — the object's tracked taint absorbs the write. out = _vt( "def f(p, o):\n o.attr = read_raw(p)\n x = o.attr\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) # o is INTEGRAL (param at function_taint); least_trusted(INTEGRAL, UNKNOWN_RAW) # = UNKNOWN_RAW; o.attr read carries o's taint. @@ -603,7 +609,8 @@ def test_nested_subscript_write_taints_root_name() -> None: # d[a][b] = read_raw(p) — the ROOT Name d absorbs the taint. out = _vt( "def f(p, d):\n d[a][b] = read_raw(p)\n x = d\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["d"] == T.UNKNOWN_RAW assert out["x"] == T.UNKNOWN_RAW @@ -613,7 +620,8 @@ def test_augassign_subscript_target_taints_base() -> None: # d[k] += read_raw(p) — the base d absorbs the RHS taint via least_trusted. out = _vt( "def f(p, d):\n d[k] += read_raw(p)\n x = d\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["d"] == T.UNKNOWN_RAW @@ -624,7 +632,8 @@ def test_annassign_attribute_target_taints_base() -> None: # else a later read of self.x reads it back at the creation taint. out = _vt( "def f(p, o):\n o.x: str = read_raw(p)\n y = o.x\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["o"] == T.UNKNOWN_RAW assert out["y"] == T.UNKNOWN_RAW @@ -634,7 +643,8 @@ def test_annassign_subscript_target_taints_base() -> None: # d['k']: str = read_raw(p) — annotated subscript write contaminates the base. out = _vt( "def f(p, d):\n d['k']: str = read_raw(p)\n x = d\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["d"] == T.UNKNOWN_RAW assert out["x"] == T.UNKNOWN_RAW @@ -677,7 +687,8 @@ def test_str_builtin_constant_arg_stays_integral() -> None: def test_format_builtin_joins_args() -> None: out = _vt( "def f(p):\n x = format(read_raw(p))\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -686,7 +697,8 @@ def test_next_builtin_propagates_iterator_taint() -> None: # next(genexp) — the iterator arg's taint propagates through. out = _vt( "def f(p):\n x = next(y for y in [read_raw(p)])\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -696,7 +708,8 @@ def test_format_method_combines_receiver_and_args_via_least_trusted() -> None: # least_trusted(INTEGRAL receiver, UNKNOWN_RAW) = UNKNOWN_RAW (still fires). out = _vt( "def f(p):\n x = '{}'.format(read_raw(p))\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -706,7 +719,8 @@ def test_format_method_validated_arg_stays_clean() -> None: # ASSURED) = ASSURED — no false positive. (taint_join would give MIXED_RAW.) out = _vt( "def f(p):\n x = '{}'.format(validate(p))\n", - function_taint=T.ASSURED, taint_map={"validate": T.ASSURED}, + function_taint=T.ASSURED, + taint_map={"validate": T.ASSURED}, ) assert out["x"] == T.ASSURED @@ -716,7 +730,8 @@ def test_join_method_combines_receiver_and_args_via_least_trusted() -> None: # = UNKNOWN_RAW (still fires). out = _vt( "def f(p):\n x = ','.join([read_raw(p)])\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -726,7 +741,8 @@ def test_join_method_validated_arg_stays_clean() -> None: # NOT demote the ASSURED element — least_trusted(INTEGRAL, ASSURED) = ASSURED. out = _vt( "def f(p):\n x = ','.join([validate(p)])\n", - function_taint=T.ASSURED, taint_map={"validate": T.ASSURED}, + function_taint=T.ASSURED, + taint_map={"validate": T.ASSURED}, ) assert out["x"] == T.ASSURED @@ -734,7 +750,8 @@ def test_join_method_validated_arg_stays_clean() -> None: def test_dict_get_carries_receiver_taint() -> None: out = _vt( "def f(p):\n d = {'k': read_raw(p)}\n x = d.get('k')\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -742,7 +759,8 @@ def test_dict_get_carries_receiver_taint() -> None: def test_dict_pop_carries_receiver_taint() -> None: out = _vt( "def f(p):\n d = {'k': read_raw(p)}\n x = d.pop('k')\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -750,7 +768,8 @@ def test_dict_pop_carries_receiver_taint() -> None: def test_dict_setdefault_carries_receiver_taint() -> None: out = _vt( "def f(p):\n d = {'k': read_raw(p)}\n x = d.setdefault('k')\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -762,7 +781,8 @@ def test_dict_get_joins_tainted_default_arg() -> None: # default propagates an existing taint (not a new SOURCE). out = _vt( "def f(p):\n d = {}\n x = d.get('k', read_raw(p))\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) # receiver d is INTEGRAL ({}), default is UNKNOWN_RAW → least_trusted = UNKNOWN_RAW # (raw default propagates and fires PY-WL-101; NOT the laundered INTEGRAL). @@ -773,7 +793,8 @@ def test_dict_get_clean_constant_default_stays_integral() -> None: # Clean counterpart: an INTEGRAL container + constant default stays INTEGRAL. out = _vt( "def f(p):\n d = {}\n x = d.get('k', 'fallback')\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.INTEGRAL @@ -781,7 +802,8 @@ def test_dict_get_clean_constant_default_stays_integral() -> None: def test_dict_pop_joins_tainted_default_arg() -> None: out = _vt( "def f(p):\n d = {}\n x = d.pop('k', read_raw(p))\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) # receiver INTEGRAL, default UNKNOWN_RAW → least_trusted = UNKNOWN_RAW (raw zone, fires). assert out["x"] == T.UNKNOWN_RAW @@ -790,23 +812,17 @@ def test_dict_pop_joins_tainted_default_arg() -> None: def test_unknown_builtin_call_still_falls_back_to_function_taint() -> None: # HARD BOUNDARY: len()/int()/validate() are NOT curated — they must STILL fall # back to function_taint, or we explode in false positives. - assert _vt( - "def f(p):\n x = len(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM - )["x"] == T.GUARDED - assert _vt( - "def f(p):\n x = int(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM - )["x"] == T.GUARDED - assert _vt( - "def f(p):\n x = validate(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM - )["x"] == T.GUARDED + assert _vt("def f(p):\n x = len(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED + assert _vt("def f(p):\n x = int(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED + assert ( + _vt("def f(p):\n x = validate(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED + ) def test_serialisation_sink_still_wins_over_generic_method() -> None: # json.dumps is a sink (UNKNOWN_RAW) and must keep precedence over the generic # .format/.join/.get method handling — sink check stays AHEAD. - out = _vt( - "def f(p):\n x = json.dumps(p)\n", function_taint=T.INTEGRAL, taint_map=_RAW_TM - ) + out = _vt("def f(p):\n x = json.dumps(p)\n", function_taint=T.INTEGRAL, taint_map=_RAW_TM) assert out["x"] == T.UNKNOWN_RAW @@ -815,7 +831,8 @@ def test_mapped_method_still_wins_over_generic_get() -> None: # method) must win over the generic .get receiver-propagation. out = _vt( "def f(p):\n x = self.get(p)\n", - function_taint=T.INTEGRAL, taint_map={"self.get": T.ASSURED}, + function_taint=T.INTEGRAL, + taint_map={"self.get": T.ASSURED}, ) assert out["x"] == T.ASSURED @@ -831,7 +848,8 @@ def test_join_via_local_var_does_not_demote_validated_data() -> None: # (migration wardline-4d9f840c24) — no combiner uses taint_join. out = _vt( "def f(p):\n v = validate(p)\n x = ','.join([v])\n", - function_taint=T.ASSURED, taint_map={"validate": T.ASSURED}, + function_taint=T.ASSURED, + taint_map={"validate": T.ASSURED}, ) assert out["x"] == T.ASSURED @@ -851,7 +869,8 @@ def test_binop_validated_operand_stays_clean() -> None: # would wrongly yield MIXED_RAW — a false positive on validated data. out = _vt( "def f(p):\n v = validate(p)\n x = '' + v\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -861,7 +880,8 @@ def test_binop_raw_operand_propagates_precise_rank() -> None: # propagates (and fires), just at its precise rank rather than MIXED_RAW. out = _vt( "def f(p):\n x = '' + read_raw(p)\n", - function_taint=T.INTEGRAL, taint_map=_RAW_TM, + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, ) assert out["x"] == T.UNKNOWN_RAW @@ -870,7 +890,8 @@ def test_ifexp_validated_branches_stay_clean() -> None: # validate(p) if c else 'fallback': least_trusted(ASSURED, INTEGRAL) = ASSURED. out = _vt( "def f(p):\n v = validate(p)\n x = v if p else 'fallback'\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -879,7 +900,8 @@ def test_boolop_validated_operand_stays_clean() -> None: # validate(p) or 'fallback': least_trusted(ASSURED, INTEGRAL) = ASSURED. out = _vt( "def f(p):\n v = validate(p)\n x = v or 'fallback'\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -888,7 +910,8 @@ def test_list_validated_element_stays_clean() -> None: # ['lit', validate(p)]: least_trusted(INTEGRAL, ASSURED) = ASSURED. out = _vt( "def f(p):\n v = validate(p)\n x = ['lit', v]\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -897,7 +920,8 @@ def test_dict_literal_validated_value_stays_clean() -> None: # {'k': validate(p), 'j': 'lit'}: least_trusted(ASSURED, INTEGRAL) = ASSURED. out = _vt( "def f(p):\n v = validate(p)\n x = {'k': v, 'j': 'lit'}\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -906,7 +930,8 @@ def test_dictcomp_validated_value_stays_clean() -> None: # {k: validate(p) for k in range(1)}: least_trusted(INTEGRAL key, ASSURED) = ASSURED. out = _vt( "def f(p):\n v = validate(p)\n x = {k: v for k in range(1)}\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -917,7 +942,8 @@ def test_dict_get_validated_default_stays_clean() -> None: # test_dict_get_joins_tainted_default_arg.) out = _vt( "def f(p):\n v = validate(p)\n d = {}\n x = d.get('k', v)\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["x"] == T.ASSURED @@ -926,7 +952,8 @@ def test_augassign_validated_operand_stays_clean() -> None: # s = ''; s += validate(p): least_trusted(INTEGRAL, ASSURED) = ASSURED. out = _vt( "def f(p):\n v = validate(p)\n s = ''\n s += v\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["s"] == T.ASSURED @@ -936,7 +963,8 @@ def test_container_write_validated_value_stays_clean() -> None: # = ASSURED — clean. A RAW write still contaminates (see Part B tests). out = _vt( "def f(p):\n v = validate(p)\n d = {}\n d['k'] = v\n x = d\n", - function_taint=T.INTEGRAL, taint_map=_VALIDATE_TM, + function_taint=T.INTEGRAL, + taint_map=_VALIDATE_TM, ) assert out["d"] == T.ASSURED assert out["x"] == T.ASSURED diff --git a/tests/unit/scanner/test_analyzer.py b/tests/unit/scanner/test_analyzer.py index cfbdb851..4089d3d3 100644 --- a/tests/unit/scanner/test_analyzer.py +++ b/tests/unit/scanner/test_analyzer.py @@ -21,13 +21,13 @@ def _write(root: Path, rel: str, src: str) -> Path: def test_analyzer_emits_metrics_and_computes_transitive_taint(tmp_path) -> None: # io_layer.read_raw is anchored MIXED_RAW via a provider; flows up. _write(tmp_path, "pkg/io_layer.py", "def read_raw(p):\n return p\n") - _write(tmp_path, "pkg/service.py", - "from pkg.io_layer import read_raw\ndef fetch(p):\n return read_raw(p)\n") + _write(tmp_path, "pkg/service.py", "from pkg.io_layer import read_raw\ndef fetch(p):\n return read_raw(p)\n") files = [tmp_path / "pkg/io_layer.py", tmp_path / "pkg/service.py"] class _Provider: def taint_for(self, entity, ctx): # noqa: ANN001, ANN201 from wardline.scanner.taint.provider import FunctionTaint + if entity.qualname.endswith(".read_raw"): return FunctionTaint(body_taint=T.MIXED_RAW, return_taint=T.MIXED_RAW) return None @@ -51,9 +51,7 @@ def test_analyzer_emits_unknown_import_fact(tmp_path) -> None: _write(tmp_path, "app.py", "from some_external_lib import thing\ndef f(): return thing()\n") analyzer = WardlineAnalyzer() findings = analyzer.analyze([tmp_path / "app.py"], WardlineConfig(), root=tmp_path) - assert any( - f.rule_id == "WLN-ENGINE-UNKNOWN-IMPORT" and f.kind == Kind.FACT for f in findings - ) + assert any(f.rule_id == "WLN-ENGINE-UNKNOWN-IMPORT" and f.kind == Kind.FACT for f in findings) def test_analyzer_default_provider_all_unknown_raw(tmp_path) -> None: @@ -72,15 +70,13 @@ def test_analyzer_pathological_deep_expression_skips_file_not_scan(tmp_path) -> _write(tmp_path, "deep.py", f"def deep(p):\n x = {expr}\n return x\n") _write(tmp_path, "ok.py", "def ok():\n return 1\n") analyzer = WardlineAnalyzer() - findings = analyzer.analyze( - [tmp_path / "deep.py", tmp_path / "ok.py"], WardlineConfig(), root=tmp_path - ) + findings = analyzer.analyze([tmp_path / "deep.py", tmp_path / "ok.py"], WardlineConfig(), root=tmp_path) assert any(f.rule_id == "WLN-ENGINE-FILE-SKIPPED" for f in findings) assert any(f.rule_id == "WLN-ENGINE-METRICS" for f in findings) ctx = analyzer.last_context assert ctx is not None - assert "ok.ok" in ctx.project_taints # clean file analysed - assert "deep.deep" not in ctx.project_taints # pathological file skipped + assert "ok.ok" in ctx.project_taints # clean file analysed + assert "deep.deep" not in ctx.project_taints # pathological file skipped def test_analyzer_l2_recursion_boundary_contains_per_function(monkeypatch) -> None: @@ -105,7 +101,7 @@ def _boom(func_node, function_taint, taint_map): # noqa: ANN001, ANN202 findings = analyzer.analyze([root / "m.py"], WardlineConfig(), root=root) ctx = analyzer.last_context assert ctx is not None - assert ctx.function_var_taints["m.a"] == {} # L2 contained + assert ctx.function_var_taints["m.a"] == {} # L2 contained assert "b" in ctx.function_var_taints["m.b"] or ctx.function_var_taints["m.b"] == {} # The contained function is NOT silently dropped — a FACT records the skip # so its absent return taint is observable, not an invisible under-taint. @@ -118,17 +114,19 @@ def _boom(func_node, function_taint, taint_map): # noqa: ANN001, ANN202 def test_analyzer_default_provider_seeds_from_decorators(tmp_path) -> None: # The DEFAULT provider (no provider= arg) now reads the trust vocabulary and # seeds real, non-trivial taints in both directions. - _write(tmp_path, "io_layer.py", - "from wardline.decorators import external_boundary, trusted\n" - "@external_boundary\ndef read_raw(p):\n return p\n" - "@trusted\ndef constant():\n return 1\n") + _write( + tmp_path, + "io_layer.py", + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef constant():\n return 1\n", + ) # An undecorated caller of a single source: fetch's fail-closed floor is # UNKNOWN_RAW (rank 6), which is ALREADY less-trusted than its EXTERNAL_RAW # (rank 5) callee. The engine only moves non-anchored functions toward # less-trusted, so fetch correctly stays UNKNOWN_RAW — EXTERNAL_RAW does not # "flow up" into an already-more-tainted caller. - _write(tmp_path, "service.py", - "from io_layer import read_raw\ndef fetch(p):\n return read_raw(p)\n") + _write(tmp_path, "service.py", "from io_layer import read_raw\ndef fetch(p):\n return read_raw(p)\n") files = [tmp_path / "io_layer.py", tmp_path / "service.py"] analyzer = WardlineAnalyzer() # default provider @@ -149,11 +147,14 @@ def test_analyzer_seeded_taints_drive_transitive_propagation(tmp_path) -> None: # genuinely-raw result at its PRECISE rank. taint_join would have spiked it to # MIXED_RAW (rank 7), the spurious provenance-clash over-label this migration # removes. The raw still propagates (UNKNOWN_RAW is in the firing RAW_ZONE). - _write(tmp_path, "m.py", - "from wardline.decorators import external_boundary, trusted\n" - "@external_boundary\ndef ext(p):\n return p\n" - "@trusted\ndef tru():\n return 1\n" - "def mix(p):\n a = ext(p)\n b = tru()\n return a if p else b\n") + _write( + tmp_path, + "m.py", + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef ext(p):\n return p\n" + "@trusted\ndef tru():\n return 1\n" + "def mix(p):\n a = ext(p)\n b = tru()\n return a if p else b\n", + ) analyzer = WardlineAnalyzer() analyzer.analyze([tmp_path / "m.py"], WardlineConfig(), root=tmp_path) ctx = analyzer.last_context @@ -167,9 +168,7 @@ def test_analyzer_skips_unparseable_file_with_fact(tmp_path) -> None: _write(tmp_path, "bad.py", "def f(:\n") # syntax error _write(tmp_path, "good.py", "def g(): return 1\n") analyzer = WardlineAnalyzer() - findings = analyzer.analyze( - [tmp_path / "bad.py", tmp_path / "good.py"], WardlineConfig(), root=tmp_path - ) + findings = analyzer.analyze([tmp_path / "bad.py", tmp_path / "good.py"], WardlineConfig(), root=tmp_path) assert any(f.rule_id == "WLN-ENGINE-PARSE-ERROR" and f.kind == Kind.FACT for f in findings) assert analyzer.last_context is not None assert "good.g" in analyzer.last_context.project_taints @@ -184,14 +183,8 @@ def test_analyzer_emits_no_module_fact(tmp_path) -> None: _write(tmp_path, "__init__.py", "VERSION = 1\n") _write(tmp_path, "mod.py", "def g(): return 1\n") analyzer = WardlineAnalyzer() - findings = analyzer.analyze( - [tmp_path / "__init__.py", tmp_path / "mod.py"], WardlineConfig(), root=tmp_path - ) - skip = [ - f - for f in findings - if f.rule_id == "WLN-ENGINE-NO-MODULE" and f.location.path == "__init__.py" - ] + findings = analyzer.analyze([tmp_path / "__init__.py", tmp_path / "mod.py"], WardlineConfig(), root=tmp_path) + skip = [f for f in findings if f.rule_id == "WLN-ENGINE-NO-MODULE" and f.location.path == "__init__.py"] assert len(skip) == 1 assert skip[0].kind == Kind.FACT assert skip[0].properties.get("reason") == "no_module_mapping" @@ -207,17 +200,23 @@ def test_analyzer_exposes_return_taints_and_resolves_validators(tmp_path) -> Non # A @trusted(ASSURED) caller that returns the VALIDATED value must see ASSURED # (the validator's RETURN), not EXTERNAL_RAW (its body) — proving the call # bucket now resolves callee RETURN taints. - _write(tmp_path, "io_layer.py", - "from wardline.decorators import external_boundary, trust_boundary\n" - "@external_boundary\ndef read_raw(p):\n return p\n" - "@trust_boundary(to_level='ASSURED')\n" - "def validate(p):\n if not p:\n raise ValueError\n return p\n") - _write(tmp_path, "service.py", - "from wardline.decorators import trusted\n" - "from io_layer import read_raw, validate\n" - "@trusted(level='ASSURED')\n" - "def safe(p):\n return validate(read_raw(p))\n" - "@trusted\ndef leaky(p):\n return read_raw(p)\n") + _write( + tmp_path, + "io_layer.py", + "from wardline.decorators import external_boundary, trust_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trust_boundary(to_level='ASSURED')\n" + "def validate(p):\n if not p:\n raise ValueError\n return p\n", + ) + _write( + tmp_path, + "service.py", + "from wardline.decorators import trusted\n" + "from io_layer import read_raw, validate\n" + "@trusted(level='ASSURED')\n" + "def safe(p):\n return validate(read_raw(p))\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n", + ) files = [tmp_path / "io_layer.py", tmp_path / "service.py"] analyzer = WardlineAnalyzer() analyzer.analyze(files, WardlineConfig(), root=tmp_path) @@ -227,5 +226,5 @@ def test_analyzer_exposes_return_taints_and_resolves_validators(tmp_path) -> Non assert ctx.project_return_taints["io_layer.validate"] == T.ASSURED assert ctx.project_taints["io_layer.validate"] == T.EXTERNAL_RAW # body unchanged # actual returned-value taint per function - assert ctx.function_return_taints["service.safe"] == T.ASSURED # validated -> clean + assert ctx.function_return_taints["service.safe"] == T.ASSURED # validated -> clean assert ctx.function_return_taints["service.leaky"] == T.EXTERNAL_RAW # leaks raw diff --git a/tests/unit/scanner/test_ast_primitives.py b/tests/unit/scanner/test_ast_primitives.py index 83757d16..a69e886a 100644 --- a/tests/unit/scanner/test_ast_primitives.py +++ b/tests/unit/scanner/test_ast_primitives.py @@ -46,28 +46,20 @@ def test_nested_import_ignored() -> None: def test_relative_import_in_module() -> None: # module pkg.sub.mod ; `from . import x` -> pkg.sub.x - assert _alias_map( - "from . import x\n", module_path="pkg.sub.mod" - ) == {"x": "pkg.sub.x"} + assert _alias_map("from . import x\n", module_path="pkg.sub.mod") == {"x": "pkg.sub.x"} def test_relative_import_with_submodule() -> None: - assert _alias_map( - "from .helpers import check\n", module_path="pkg.sub.mod" - ) == {"check": "pkg.sub.helpers.check"} + assert _alias_map("from .helpers import check\n", module_path="pkg.sub.mod") == {"check": "pkg.sub.helpers.check"} def test_relative_import_in_package_init() -> None: # pkg/__init__.py : current package is `pkg`, not `pkg`'s parent. - assert _alias_map( - "from . import x\n", module_path="pkg", is_package=True - ) == {"x": "pkg.x"} + assert _alias_map("from . import x\n", module_path="pkg", is_package=True) == {"x": "pkg.x"} def test_double_relative_import() -> None: - assert _alias_map( - "from ..other import y\n", module_path="pkg.sub.mod" - ) == {"y": "pkg.other.y"} + assert _alias_map("from ..other import y\n", module_path="pkg.sub.mod") == {"y": "pkg.other.y"} def _func(src: str) -> ast.FunctionDef: @@ -135,23 +127,17 @@ def _call(src: str) -> ast.Call: def test_resolve_bare_name_local_function() -> None: - fqn = resolve_call_fqn( - _call("foo()"), {}, frozenset({"pkg.mod.foo"}), "pkg.mod" - ) + fqn = resolve_call_fqn(_call("foo()"), {}, frozenset({"pkg.mod.foo"}), "pkg.mod") assert fqn == "pkg.mod.foo" def test_resolve_bare_name_via_import_alias() -> None: - fqn = resolve_call_fqn( - _call("check()"), {"check": "other.check"}, frozenset(), "pkg.mod" - ) + fqn = resolve_call_fqn(_call("check()"), {"check": "other.check"}, frozenset(), "pkg.mod") assert fqn == "other.check" def test_local_takes_precedence_over_import() -> None: - fqn = resolve_call_fqn( - _call("foo()"), {"foo": "elsewhere.foo"}, frozenset({"pkg.mod.foo"}), "pkg.mod" - ) + fqn = resolve_call_fqn(_call("foo()"), {"foo": "elsewhere.foo"}, frozenset({"pkg.mod.foo"}), "pkg.mod") assert fqn == "pkg.mod.foo" @@ -160,9 +146,7 @@ def test_resolve_bare_name_unresolved() -> None: def test_resolve_attribute_via_alias() -> None: - fqn = resolve_call_fqn( - _call("mod.func()"), {"mod": "pkg.mod"}, frozenset(), "caller.mod" - ) + fqn = resolve_call_fqn(_call("mod.func()"), {"mod": "pkg.mod"}, frozenset(), "caller.mod") assert fqn == "pkg.mod.func" @@ -210,22 +194,28 @@ def test_nested_class_self_method_resolves() -> None: def test_self_method_not_in_project_is_none() -> None: call = _stmt_call("self.absent()") - assert resolve_self_method_fqn( - call, caller_class_fqn="pkg.mod.Cls", project_fqns=frozenset() - ) is None + assert resolve_self_method_fqn(call, caller_class_fqn="pkg.mod.Cls", project_fqns=frozenset()) is None def test_non_self_receiver_is_none() -> None: call = _stmt_call("other.method()") - assert resolve_self_method_fqn( - call, caller_class_fqn="pkg.mod.Cls", - project_fqns=frozenset({"pkg.mod.Cls.method"}), - ) is None + assert ( + resolve_self_method_fqn( + call, + caller_class_fqn="pkg.mod.Cls", + project_fqns=frozenset({"pkg.mod.Cls.method"}), + ) + is None + ) def test_no_caller_class_is_none() -> None: call = _stmt_call("self.helper()") - assert resolve_self_method_fqn( - call, caller_class_fqn=None, - project_fqns=frozenset({"pkg.mod.Cls.helper"}), - ) is None + assert ( + resolve_self_method_fqn( + call, + caller_class_fqn=None, + project_fqns=frozenset({"pkg.mod.Cls.helper"}), + ) + is None + ) diff --git a/tests/unit/scanner/test_context.py b/tests/unit/scanner/test_context.py index ecd3ca01..048c9b53 100644 --- a/tests/unit/scanner/test_context.py +++ b/tests/unit/scanner/test_context.py @@ -33,16 +33,25 @@ def test_empty_registry_runs_no_rules() -> None: reg = RuleRegistry() assert reg.rules == () ctx = AnalysisContext( - project_taints={}, project_return_taints={}, function_var_taints={}, - function_return_taints={}, function_return_callee={}, entities={}, taint_provenance={} + project_taints={}, + project_return_taints={}, + function_var_taints={}, + function_return_taints={}, + function_return_callee={}, + entities={}, + taint_provenance={}, ) assert reg.run(ctx) == [] def test_registry_runs_registered_rule() -> None: finding = Finding( - rule_id="X", message="m", severity=Severity.INFO, kind=Kind.FACT, - location=Location(path="m.py"), fingerprint="fp", + rule_id="X", + message="m", + severity=Severity.INFO, + kind=Kind.FACT, + location=Location(path="m.py"), + fingerprint="fp", ) class _Rule: @@ -54,8 +63,13 @@ def check(self, context: AnalysisContext): # noqa: ANN201, ARG002 reg = RuleRegistry() reg.register(_Rule()) ctx = AnalysisContext( - project_taints={}, project_return_taints={}, function_var_taints={}, - function_return_taints={}, function_return_callee={}, entities={}, taint_provenance={} + project_taints={}, + project_return_taints={}, + function_var_taints={}, + function_return_taints={}, + function_return_callee={}, + entities={}, + taint_provenance={}, ) assert reg.run(ctx) == [finding] assert len(reg.rules) == 1 diff --git a/tests/unit/scanner/test_diagnostics.py b/tests/unit/scanner/test_diagnostics.py index d184c5a6..2f2942e5 100644 --- a/tests/unit/scanner/test_diagnostics.py +++ b/tests/unit/scanner/test_diagnostics.py @@ -60,8 +60,10 @@ def test_unknown_diagnostic_code_is_error_not_silent() -> None: def test_diagnose_unknown_imports_flags_external_named_import() -> None: tree = ast.parse("from external_pkg import thing\n") out = diagnose_unknown_imports( - tree=tree, module_path="m", - project_modules=frozenset({"m"}), stdlib_keys=frozenset(), + tree=tree, + module_path="m", + project_modules=frozenset({"m"}), + stdlib_keys=frozenset(), ) assert len(out) == 1 assert out[0][0] == "m" @@ -72,12 +74,14 @@ def test_diagnose_unknown_imports_skips_stdlib_and_project_and_relative() -> Non tree = ast.parse( "import os\n" "from typing import TYPE_CHECKING\n" - "from m import sibling\n" # project module - "from . import rel\n" # relative + "from m import sibling\n" # project module + "from . import rel\n" # relative ) out = diagnose_unknown_imports( - tree=tree, module_path="m.sub", - project_modules=frozenset({"m", "m.sub"}), stdlib_keys=frozenset(), + tree=tree, + module_path="m.sub", + project_modules=frozenset({"m", "m.sub"}), + stdlib_keys=frozenset(), ) assert out == [] @@ -92,7 +96,5 @@ def test_unknown_import_findings_are_facts() -> None: assert findings[0].kind == Kind.FACT assert findings[0].rule_id == "WLN-ENGINE-UNKNOWN-IMPORT" # Fingerprint stable from (module, package) — not message text. - again = build_unknown_import_findings( - [("pkg/mod.py", "pkg.mod", tree)], project_modules=frozenset({"pkg.mod"}) - ) + again = build_unknown_import_findings([("pkg/mod.py", "pkg.mod", tree)], project_modules=frozenset({"pkg.mod"})) assert findings[0].fingerprint == again[0].fingerprint diff --git a/tests/unit/scanner/test_index.py b/tests/unit/scanner/test_index.py index bb4f75d7..fba19e38 100644 --- a/tests/unit/scanner/test_index.py +++ b/tests/unit/scanner/test_index.py @@ -8,10 +8,7 @@ def _quals(src: str, module: str = "demo", path: str = "demo.py") -> list[tuple[str, str]]: tree = ast.parse(src) - return [ - (e.qualname, e.kind) - for e in discover_file_entities(tree, module=module, path=path) - ] + return [(e.qualname, e.kind) for e in discover_file_entities(tree, module=module, path=path)] def test_module_function_and_closure() -> None: @@ -23,13 +20,7 @@ def test_module_function_and_closure() -> None: def test_methods_and_nested_class_in_closure() -> None: - src = ( - "class Foo:\n" - " def bar(self):\n" - " class Local:\n" - " def meth(self):\n" - " pass\n" - ) + src = "class Foo:\n def bar(self):\n class Local:\n def meth(self):\n pass\n" assert _quals(src) == [ ("demo.Foo.bar", "method"), ("demo.Foo.bar..Local.meth", "method"), @@ -85,9 +76,7 @@ def test_property_setter_collapses_first_wins() -> None: # Prove the GETTER survived (not last-wins): its node carries @property and # returns 1; the dropped setter has the @x.setter decorator and a bare pass. surviving = entities[0].node - assert [d.id for d in surviving.decorator_list if isinstance(d, ast.Name)] == [ - "property" - ] + assert [d.id for d in surviving.decorator_list if isinstance(d, ast.Name)] == ["property"] ret = surviving.body[0] assert isinstance(ret, ast.Return) assert isinstance(ret.value, ast.Constant) @@ -123,21 +112,13 @@ def test_location_anchors_on_def_line() -> None: def test_returns_entity_instances() -> None: - entities = discover_file_entities( - ast.parse("def f():\n pass\n"), module="demo", path="demo.py" - ) + entities = discover_file_entities(ast.parse("def f():\n pass\n"), module="demo", path="demo.py") assert all(isinstance(e, Entity) for e in entities) assert isinstance(entities[0].node, ast.FunctionDef) def test_discover_class_qualnames_top_level_and_nested() -> None: - src = ( - "class Outer:\n" - " def m(self): pass\n" - " class Inner:\n" - " def n(self): pass\n" - "def free(): pass\n" - ) + src = "class Outer:\n def m(self): pass\n class Inner:\n def n(self): pass\ndef free(): pass\n" tree = ast.parse(src) classes = discover_class_qualnames(tree, module="pkg.mod") assert classes == {"pkg.mod.Outer", "pkg.mod.Outer.Inner"} @@ -147,11 +128,7 @@ def test_class_qualname_is_rsplit_prefix_of_its_methods() -> None: # The invariant the callgraph relies on: a method's enclosing class qualname # equals method_qualname.rsplit('.', 1)[0], and is built by the SAME # reconstruct_qualname as the methods. - src = ( - "class Outer:\n" - " class Inner:\n" - " def n(self): pass\n" - ) + src = "class Outer:\n class Inner:\n def n(self): pass\n" tree = ast.parse(src) entities = discover_file_entities(tree, module="pkg.mod", path="pkg/mod.py") classes = discover_class_qualnames(tree, module="pkg.mod") diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..d4df6b62 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1131 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + +[[package]] +name = "blake3" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/a0/b7b6dff04012cfd6e665c09ee446f749bd8ea161b00f730fe1bdecd0f033/blake3-1.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8da4233984d51471bd4e4366feda1d90d781e712e0a504ea54b1f2b3577557b", size = 347983, upload-time = "2025-10-14T06:45:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/264091cac31d7ae913f1f296abc20b8da578b958ffb86100a7ce80e8bf5c/blake3-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1257be19f2d381c868a34cc822fc7f12f817ddc49681b6d1a2790bfbda1a9865", size = 325415, upload-time = "2025-10-14T06:45:48.482Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/85a4c0782f613de23d114a7a78fcce270f75b193b3ff3493a0de24ba104a/blake3-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f255b110840e52b6ce9db02217e39660ebad3e34ddd5bca8b8d378a77e4e1", size = 371296, upload-time = "2025-10-14T06:45:49.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/488475254976ed93fab57c67aa80d3b40df77f7d9db6528c9274bff53e08/blake3-1.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66ca28a673025c40db3eba21a9cac52f559f83637efa675b3f6bd8683f0415f3", size = 374516, upload-time = "2025-10-14T06:45:51.23Z" }, + { url = "https://files.pythonhosted.org/packages/7b/21/2a1c47fedb77fb396512677ec6d46caf42ac6e9a897db77edd0a2a46f7bb/blake3-1.0.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb04966537777af56c1f399b35525aa70a1225816e121ff95071c33c0f7abca", size = 447911, upload-time = "2025-10-14T06:45:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/db0626df16029713e7e61b67314c4835e85c296d82bd907c21c6ea271da2/blake3-1.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5b5da177d62cc4b7edf0cea08fe4dec960c9ac27f916131efa890a01f747b93", size = 505420, upload-time = "2025-10-14T06:45:54.445Z" }, + { url = "https://files.pythonhosted.org/packages/5b/55/6e737850c2d58a6d9de8a76dad2ae0f75b852a23eb4ecb07a0b165e6e436/blake3-1.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38209b10482c97e151681ea3e91cc7141f56adbbf4820a7d701a923124b41e6a", size = 394189, upload-time = "2025-10-14T06:45:55.719Z" }, + { url = "https://files.pythonhosted.org/packages/5b/94/eafaa5cdddadc0c9c603a6a6d8339433475e1a9f60c8bb9c2eed2d8736b6/blake3-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504d1399b7fb91dfe5c25722d2807990493185faa1917456455480c36867adb5", size = 388001, upload-time = "2025-10-14T06:45:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/17/81/735fa00d13de7f68b25e1b9cb36ff08c6f165e688d85d8ec2cbfcdedccc5/blake3-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c84af132aa09abeadf9a0118c8fb26f4528f3f42c10ef8be0fcf31c478774ec4", size = 550302, upload-time = "2025-10-14T06:45:58.657Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/d1fe8bdea4a6088bd54b5a58bc40aed89a4e784cd796af7722a06f74bae7/blake3-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a25db3d36b55f5ed6a86470155cc749fc9c5b91c949b8d14f48658f9d960d9ec", size = 554211, upload-time = "2025-10-14T06:46:00.269Z" }, + { url = "https://files.pythonhosted.org/packages/55/d1/ca74aa450cbe10e396e061f26f7a043891ffa1485537d6b30d3757e20995/blake3-1.0.8-cp312-cp312-win32.whl", hash = "sha256:e0fee93d5adcd44378b008c147e84f181f23715307a64f7b3db432394bbfce8b", size = 228343, upload-time = "2025-10-14T06:46:01.533Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/bbd02647169e3fbed27558555653ac2578c6f17ccacf7d1956c58ef1d214/blake3-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:6a6eafc29e4f478d365a87d2f25782a521870c8514bb43734ac85ae9be71caf7", size = 215704, upload-time = "2025-10-14T06:46:02.79Z" }, + { url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" }, + { url = "https://files.pythonhosted.org/packages/62/cd/765b76bb48b8b294fea94c9008b0d82b4cfa0fa2f3c6008d840d01a597e4/blake3-1.0.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f54cff7f15d91dc78a63a2dd02a3dccdc932946f271e2adb4130e0b4cf608ba", size = 374372, upload-time = "2025-10-14T06:46:08.698Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/32084eadbb28592bb07298f0de316d2da586c62f31500a6b1339a7e7b29b/blake3-1.0.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7e12a777f6b798eb8d06f875d6e108e3008bd658d274d8c676dcf98e0f10537", size = 447627, upload-time = "2025-10-14T06:46:10.002Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f4/3788a1d86e17425eea147e28d7195d7053565fc279236a9fd278c2ec495e/blake3-1.0.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddfc59b0176fb31168f08d5dd536e69b1f4f13b5a0f4b0c3be1003efd47f9308", size = 507536, upload-time = "2025-10-14T06:46:11.614Z" }, + { url = "https://files.pythonhosted.org/packages/fe/01/4639cba48513b94192681b4da472cdec843d3001c5344d7051ee5eaef606/blake3-1.0.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2336d5b2a801a7256da21150348f41610a6c21dae885a3acb1ebbd7333d88d8", size = 394105, upload-time = "2025-10-14T06:46:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/21/ae/6e55c19c8460fada86cd1306a390a09b0c5a2e2e424f9317d2edacea439f/blake3-1.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4072196547484c95a5a09adbb952e9bb501949f03f9e2a85e7249ef85faaba8", size = 386928, upload-time = "2025-10-14T06:46:16.284Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6c/05b7a5a907df1be53a8f19e7828986fc6b608a44119641ef9c0804fbef15/blake3-1.0.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0eab3318ec02f8e16fe549244791ace2ada2c259332f0c77ab22cf94dfff7130", size = 550003, upload-time = "2025-10-14T06:46:17.791Z" }, + { url = "https://files.pythonhosted.org/packages/b4/03/f0ea4adfedc1717623be6460b3710fcb725ca38082c14274369803f727e1/blake3-1.0.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a33b9a1fb6d1d559a8e0d04b041e99419a6bb771311c774f6ff57ed7119c70ed", size = 553857, upload-time = "2025-10-14T06:46:19.088Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6f/e5410d2e2a30c8aba8389ffc1c0061356916bf5ecd0a210344e7b69b62ab/blake3-1.0.8-cp313-cp313-win32.whl", hash = "sha256:e171b169cb7ea618e362a4dddb7a4d4c173bbc08b9ba41ea3086dd1265530d4f", size = 228315, upload-time = "2025-10-14T06:46:20.391Z" }, + { url = "https://files.pythonhosted.org/packages/79/ef/d9c297956dfecd893f29f59e7b22445aba5b47b7f6815d9ba5dcd73fcae6/blake3-1.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:3168c457255b5d2a2fc356ba696996fcaff5d38284f968210d54376312107662", size = 215477, upload-time = "2025-10-14T06:46:21.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/ba/eaa7723d66dd8ab762a3e85e139bb9c46167b751df6e950ad287adb8fb61/blake3-1.0.8-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4d672c24dc15ec617d212a338a4ca14b449829b6072d09c96c63b6e6b621aed", size = 347289, upload-time = "2025-10-14T06:46:22.772Z" }, + { url = "https://files.pythonhosted.org/packages/47/b3/6957f6ee27f0d5b8c4efdfda68a1298926a88c099f4dd89c711049d16526/blake3-1.0.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1af0e5a29aa56d4fba904452ae784740997440afd477a15e583c38338e641f41", size = 324444, upload-time = "2025-10-14T06:46:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/13/da/722cebca11238f3b24d3cefd2361c9c9ea47cfa0ad9288eeb4d1e0b7cf93/blake3-1.0.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef153c5860d5bf1cc71aece69b28097d2a392913eb323d6b52555c875d0439fc", size = 370441, upload-time = "2025-10-14T06:46:26.29Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d5/2f7440c8e41c0af995bad3a159e042af0f4ed1994710af5b4766ca918f65/blake3-1.0.8-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ae3689f0c7bfa6ce6ae45cab110e4c3442125c4c23b28f1f097856de26e4d1", size = 374312, upload-time = "2025-10-14T06:46:27.451Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6c/fb6a7812e60ce3e110bcbbb11f167caf3e975c589572c41e1271f35f2c41/blake3-1.0.8-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb83532f7456ddeb68dae1b36e1f7c52f9cb72852ac01159bbcb1a12b0f8be0", size = 447007, upload-time = "2025-10-14T06:46:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/3b/c99b43fae5047276ea9d944077c190fc1e5f22f57528b9794e21f7adedc6/blake3-1.0.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae7754c7d96e92a70a52e07c732d594cf9924d780f49fffd3a1e9235e0f5ba7", size = 507323, upload-time = "2025-10-14T06:46:30.661Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/ba90eddd592f8c074a0694cb0a744b6bd76bfe67a14c2b490c8bdfca3119/blake3-1.0.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bacaae75e98dee3b7da6c5ee3b81ee21a3352dd2477d6f1d1dbfd38cdbf158a", size = 393449, upload-time = "2025-10-14T06:46:31.805Z" }, + { url = "https://files.pythonhosted.org/packages/25/ed/58a2acd0b9e14459cdaef4344db414d4a36e329b9720921b442a454dd443/blake3-1.0.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9456c829601d72852d8ba0af8dae0610f7def1d59f5942efde1e2ef93e8a8b57", size = 386844, upload-time = "2025-10-14T06:46:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/4a/04/fed09845b18d90862100c8e48308261e2f663aab25d3c71a6a0bdda6618b/blake3-1.0.8-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:497ef8096ec4ac1ffba9a66152cee3992337cebf8ea434331d8fd9ce5423d227", size = 549550, upload-time = "2025-10-14T06:46:35.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2a/9f13ea01b03b1b4751a1cc2b6c1ef4b782e19433a59cf35b59cafb2a2696/blake3-1.0.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2c33dac2c6112bc23f961a7ca305c7e34702c8177040eb98d0389d13a347b9e1", size = 347016, upload-time = "2025-10-14T06:46:40.318Z" }, + { url = "https://files.pythonhosted.org/packages/06/8e/8458c4285fbc5de76414f243e4e0fcab795d71a8b75324e14959aee699da/blake3-1.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c445eff665d21c3b3b44f864f849a2225b1164c08654beb23224a02f087b7ff1", size = 324496, upload-time = "2025-10-14T06:46:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/b913eb9cc4af708c03e01e6b88a8bb3a74833ba4ae4b16b87e2829198e06/blake3-1.0.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47939f04b89c5c6ff1e51e883e5efab1ea1bf01a02f4d208d216dddd63d0dd8", size = 370654, upload-time = "2025-10-14T06:46:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/245e0800c33b99c8f2b570d9a7199b51803694913ee4897f339648502933/blake3-1.0.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73e0b4fa25f6e3078526a592fb38fca85ef204fd02eced6731e1cdd9396552d4", size = 374693, upload-time = "2025-10-14T06:46:45.186Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a6/8cb182c8e482071dbdfcc6ec0048271fd48bcb78782d346119ff54993700/blake3-1.0.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0543c57eb9d6dac9d4bced63e9f7f7b546886ac04cec8da3c3d9c8f30cbbb7", size = 447673, upload-time = "2025-10-14T06:46:46.358Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/1cbbb5574d2a9436d1b15e7eb5b9d82e178adcaca71a97b0fddaca4bfe3a/blake3-1.0.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed972ebd553c0c25363459e9fc71a38c045d8419e365b59acd8cd791eff13981", size = 507233, upload-time = "2025-10-14T06:46:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/9c/45/b55825d90af353b3e26c653bab278da9d6563afcf66736677f9397e465be/blake3-1.0.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bafdec95dfffa3f6571e529644744e280337df15ddd9728f224ba70c5779b23", size = 393852, upload-time = "2025-10-14T06:46:49.511Z" }, + { url = "https://files.pythonhosted.org/packages/34/73/9058a1a457dd20491d1b37de53d6876eff125e1520d9b2dd7d0acbc88de2/blake3-1.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d78f06f3fb838b34c330e2987090376145cbe5944d8608a0c4779c779618f7b", size = 386442, upload-time = "2025-10-14T06:46:51.205Z" }, + { url = "https://files.pythonhosted.org/packages/30/6d/561d537ffc17985e276e08bf4513f1c106f1fdbef571e782604dc4e44070/blake3-1.0.8-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd03ff08d1b6e4fdda1cd03826f971ae8966ef6f683a8c68aa27fb21904b5aa9", size = 549929, upload-time = "2025-10-14T06:46:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/03/2f/dbe20d2c57f1a67c63be4ba310bcebc707b945c902a0bde075d2a8f5cd5c/blake3-1.0.8-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:4e02a3c499e35bf51fc15b2738aca1a76410804c877bcd914752cac4f71f052a", size = 553750, upload-time = "2025-10-14T06:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/6b/da/c6cb712663c869b2814870c2798e57289c4268c5ac5fb12d467fce244860/blake3-1.0.8-cp314-cp314-win32.whl", hash = "sha256:a585357d5d8774aad9ffc12435de457f9e35cde55e0dc8bc43ab590a6929e59f", size = 228404, upload-time = "2025-10-14T06:46:56.807Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/c7dcd8bc3094bba1c4274e432f9e77a7df703532ca000eaa550bd066b870/blake3-1.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:9ab5998e2abd9754819753bc2f1cf3edf82d95402bff46aeef45ed392a5468bf", size = 215460, upload-time = "2025-10-14T06:46:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/75/3c/6c8afd856c353176836daa5cc33a7989e8f54569e9d53eb1c53fc8f80c34/blake3-1.0.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2df12f295f95a804338bd300e8fad4a6f54fd49bd4d9c5893855a230b5188a8", size = 347482, upload-time = "2025-10-14T06:47:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/92cd5501ce8e1f5cabdc0c3ac62d69fdb13ff0b60b62abbb2b6d0a53a790/blake3-1.0.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:63379be58438878eeb76ebe4f0efbeaabf42b79f2cff23b6126b7991588ced67", size = 324376, upload-time = "2025-10-14T06:47:01.413Z" }, + { url = "https://files.pythonhosted.org/packages/11/33/503b37220a3e2e31917ef13722efd00055af51c5e88ae30974c733d7ece6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88d527c247f9609dc1d45a08fd243e39f0d5300d54c57e048de24d4fa9240ebb", size = 370220, upload-time = "2025-10-14T06:47:02.573Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/fe817843adf59516c04d44387bd643b422a3b0400ea95c6ede6a49920737/blake3-1.0.8-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506a47897a11ebe8f3cdeb52f1365d6a2f83959e98ccb0c830f8f73277d4d358", size = 373454, upload-time = "2025-10-14T06:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/90a2a623575373dfc9b683f1bad1bf017feafa5a6d65d94fb09543050740/blake3-1.0.8-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5122a61b3b004bbbd979bdf83a3aaab432da3e2a842d7ddf1c273f2503b4884", size = 447102, upload-time = "2025-10-14T06:47:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/93/ff/4e8ce314f60115c4c657b1fdbe9225b991da4f5bcc5d1c1f1d151e2f39d6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0171e85d56dec1219abdae5f49a0ed12cb3f86a454c29160a64fd8a8166bba37", size = 506791, upload-time = "2025-10-14T06:47:06.82Z" }, + { url = "https://files.pythonhosted.org/packages/44/88/2963a1f18aab52bdcf35379b2b48c34bbc462320c37e76960636b8602c36/blake3-1.0.8-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:003f61e8c41dd9931edddf1cc6a1bb680fb2ac0ad15493ef4a1df9adc59ce9df", size = 393717, upload-time = "2025-10-14T06:47:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/45/d1/a848ed8e8d4e236b9b16381768c9ae99d92890c24886bb4505aa9c3d2033/blake3-1.0.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c3151955efb09ba58cd3e1263521e15e9e3866a40d6bd3556d86fc968e8f95", size = 386150, upload-time = "2025-10-14T06:47:10.363Z" }, + { url = "https://files.pythonhosted.org/packages/96/09/e3eb5d60f97c01de23d9f434e6e1fc117efb466eaa1f6ddbbbcb62580d6e/blake3-1.0.8-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:5eb25bca3cee2e0dd746a214784fb36be6a43640c01c55b6b4e26196e72d076c", size = 549120, upload-time = "2025-10-14T06:47:11.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/ad/3d9661c710febb8957dd685fdb3e5a861aa0ac918eda3031365ce45789e2/blake3-1.0.8-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ab4e1dea4fa857944944db78e8f20d99ee2e16b2dea5a14f514fb0607753ac83", size = 553264, upload-time = "2025-10-14T06:47:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/11/55/e332a5b49edf377d0690e95951cca21a00c568f6e37315f9749efee52617/blake3-1.0.8-cp314-cp314t-win32.whl", hash = "sha256:67f1bc11bf59464ef092488c707b13dd4e872db36e25c453dfb6e0c7498df9f1", size = 228116, upload-time = "2025-10-14T06:47:14.516Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133, upload-time = "2025-10-14T06:47:16.069Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[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 = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[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 = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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 = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-randomly" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "types-jsonschema" +version = "4.26.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wardline" +source = { editable = "." } + +[package.optional-dependencies] +clarion = [ + { name = "blake3" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, +] +scanner = [ + { name = "click" }, + { name = "jsonschema" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-randomly" }, + { name = "ruff" }, + { name = "types-jsonschema" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "blake3", marker = "extra == 'clarion'", specifier = ">=1.0" }, + { name = "click", marker = "extra == 'scanner'", specifier = ">=8.0" }, + { name = "jsonschema", marker = "extra == 'scanner'", specifier = ">=4.0" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "pyyaml", marker = "extra == 'scanner'", specifier = ">=6.0" }, +] +provides-extras = ["clarion", "docs", "scanner"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.13.0" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-randomly" }, + { name = "ruff", specifier = ">=0.8.0" }, + { name = "types-jsonschema" }, + { name = "types-pyyaml" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]