diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1856183..05981a61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: # ``opentelemetry.*`` symbols come back Unknown and pyright # produces hundreds of false-positive errors on # ``tests/unit/test_observability_otel.py``. - run: uv sync --frozen --all-extras + run: uv sync --frozen --all-extras --group examples - name: Lint (ruff check) run: uv run ruff check . diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..9de3c4a1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,69 @@ +name: Docs + +on: + pull_request: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + - "pyproject.toml" + - "src/**" + - ".github/workflows/docs.yml" + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + - "pyproject.toml" + - "src/**" + - ".github/workflows/docs.yml" + +# Least-privilege: read code for the build; ``deployments: write`` lets +# the Cloudflare Pages action attach a deployment status to the commit. +permissions: + contents: read + deployments: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # mkdocstrings imports the package source; the spec submodule + # is not referenced by docs but keep the conventional recursive + # checkout consistent with ``ci.yml``. + submodules: recursive + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Sync deps (docs group) + # ``--group docs`` installs the project itself in editable mode + # plus the ``docs`` dependency group. mkdocstrings needs to + # import ``openarmature`` to introspect docstrings, which the + # editable install provides. + run: uv sync --frozen --group docs + + - name: Build site (strict) + # ``--strict`` fails the build on any warning. Catches broken + # internal links, missing nav references, plugin misconfig + # early — before the deploy step. + run: uv run mkdocs build --strict + + - name: Deploy to Cloudflare Pages + # Only deploy on pushes to main. PR builds prove the site + # builds but do not deploy. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} + command: pages deploy site --project-name=openarmature-docs --branch=main diff --git a/.gitignore b/.gitignore index 24d66736..9445b3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ dist/ .mypy_cache/ .pyright/ +# MkDocs build output + plugin cache +site/ +.cache/ + # Coverage .coverage .coverage.* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b20203b8..a49c3f2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,11 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + # ``mkdocs.yml`` uses ``!!python/name:...`` tags (e.g. for + # Material's emoji extension) that safe_load can't parse. + # ``--unsafe`` skips constructor validation but still catches + # malformed YAML, which is the bug class this hook is for. + args: ["--unsafe"] - id: check-toml - id: check-merge-conflict - id: check-added-large-files diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..93610e71 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# AGENTS.md + +Orientation for coding agents working in this repo. `README.md` covers what +the project is and how to use it; this file covers things that aren't +obvious from reading the code. + +## Spec is the source of truth + +This repo is a Python implementation of +[`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec). +Behavior is defined by the spec; this repo executes it. + +- The spec lives at `openarmature-spec/` as a git submodule pinned to a + released tag. **Don't edit files in the submodule.** +- The pin tracks the latest **Accepted** spec version, not the tip of + `openarmature-spec/main`. `main` may contain Draft proposals merged into + the spec text; only Accepted releases ship normative behavior, and the + implementation conforms to the pinned Accepted release. +- Behavior changes that aren't already in the Accepted spec require a + proposal in the spec repo first, not a PR here. +- To bump the spec submodule after a new Accepted release: + `cd openarmature-spec && git checkout `, then bump the three + places that track the version (below). + +## Spec proposal lifecycle + +Proposals travel Draft → Accepted in the spec repo (see +`openarmature-spec/GOVERNANCE.md` for the format and flow). Proposals +live in `openarmature-spec/proposals/`; canonical spec text in +`openarmature-spec/spec//`. + +When implementing a feature, **read the relevant Accepted proposal +first** — don't infer behavior from existing impl alone. Draft proposals +don't ship; their text may change before acceptance. + +## Three places hold the spec version — keep them in sync + +- `tool.openarmature.spec_version` in `pyproject.toml` +- `__spec_version__` in `src/openarmature/__init__.py` +- The submodule commit (must match a released spec tag, e.g. `v0.10.0`) + +`tests/test_smoke.py` asserts the first two match. The third is enforced +by convention. + +## Package layout + +- `src/openarmature/graph/` — graph engine (State, GraphBuilder, + CompiledGraph, edges, projections, fan-out) +- `src/openarmature/llm/` — LLM Provider Protocol + OpenAIProvider; HTTP + error classification + retry helpers +- `src/openarmature/checkpoint/` — checkpointing protocol + in-memory + and filesystem backends +- `src/openarmature/observability/` — `[otel]` extra; OTel observer + + log bridge + correlation primitives +- `src/openarmature/middleware/` — pipeline-utility middleware + +## Test layout + +- `tests/conformance/` — runs the spec's YAML fixtures against the + engine via an adapter. Drives most of the behavior coverage. +- `tests/unit/` — fills coverage gaps the conformance suite doesn't + reach: `edge_exception`, `reducer_error`, `state_validation_error`, + `SubgraphNode.run`, projection variants, frozen-state mutation, etc. +- `tests/test_smoke.py` — version sync. + +## Tooling + +- `uv` for everything. Don't use `pip` directly. +- Pyright **strict mode** is enforced (`pyproject.toml`). Annotations + are not optional. +- Ruff for lint + format. Pre-commit hook runs `ruff format` + automatically — the file you committed may not be the file in the + next diff. +- `pytest-asyncio` with `asyncio_mode = "auto"` — `async def test_...` + works with no decorator. + +## Common commands + +```bash +uv run pytest -q # all tests +uv run pytest tests/conformance/ -v # spec conformance only +uv run ruff check . && uv run ruff format # lint + format +uv run pyright src/ tests/ # type check +uv run mkdocs serve # preview the docs site locally +``` + +## Branch + commit conventions + +Branch names use `/` (3–5 words). Allowed +types: + +- `feature/` — new functionality +- `fix/` — bug fixes +- `refactor/` — restructuring without behavior change +- `chore/` — tooling, deps, config, housekeeping +- `schema/` — data model changes + +For ticketed work, embed the ID: +`feature/PROJ-123-short-description`. + +Commit subjects follow the 50/72 rule — subject ≤ 50 chars (hard cap +72), imperative mood, capitalized, no trailing period. Body wrapped at +72 columns. Body explains *what* and *why*, not *how*. + +## Docs + +User-facing docs live in `docs/` and build via MkDocs Material; the +deployed site is at `docs.openarmature.ai`. CI build + deploy is in +`.github/workflows/docs.yml`. Local preview: `uv run mkdocs serve`. + +## Engine design notes that are easy to miss + +- `State` is `frozen=True` AND `extra="forbid"`. Nodes that return an + undeclared field surface as a `state_validation_error`, not a silent + drop. +- Conditional edges over-approximate at compile time (a conditional + from node X is treated as reaching every node), so the + unreachable-node check is sound but not tight. +- Each node has exactly one outgoing edge. Branching is via + conditional edges, not multiple statics. +- `END` is a distinct sentinel object, not a reserved string. Use the + exported `END` constant. + +## In scope / out of scope + +In scope: + +- Graph engine + the spec's runtime contract. +- Pipeline utilities (rate limiting, structured-output retry helpers). +- Observability via OTel observer (under `[otel]` extra). +- Checkpointing (in-memory + filesystem backends). +- LLM Provider Protocol + the canonical OpenAI implementation. + +Out of scope, deferred to sibling packages at v1.0: + +- `openarmature-otel` — eventual extraction of the OTel observer for + projects that don't want it in core. +- `openarmature-eval` — evaluation framework. + +Behavior changes outside the Accepted spec require a spec proposal +first. diff --git a/CLAUDE.md b/CLAUDE.md index edecc5d2..0f200a92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,48 +1,3 @@ # CLAUDE.md -Orientation for Claude Code sessions in this repo. The `README.md` covers what the project is and how to use it; this file covers things that aren't obvious from reading the code. - -## Spec is the source of truth - -This repo is a Python implementation of [`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec). Behavior is defined by the spec; this repo executes it. - -- The spec lives at `openarmature-spec/` as a git submodule pinned to a released tag. Don't edit files in the submodule. -- To bump the spec: `cd openarmature-spec && git checkout `, then bump the three places that track the spec version (see below). -- Behavior changes that aren't already in the spec require a proposal in the spec repo first, not a PR here. - -## Three places hold the spec version — keep them in sync - -- `tool.openarmature.spec_version` in `pyproject.toml` -- `__spec_version__` in `src/openarmature/__init__.py` -- The submodule commit (must match a released tag, e.g. `v0.1.1`) - -`tests/test_smoke.py` asserts the first two match. The third is enforced by convention. - -## Test layout - -- `tests/conformance/` — runs the spec's YAML fixtures against the engine via an adapter. Drives most of the behavior coverage. -- `tests/unit/` — fills coverage gaps the conformance suite doesn't reach: `edge_exception`, `reducer_error`, `state_validation_error`, `SubgraphNode.run`, projection variants, frozen-state mutation, etc. -- `tests/test_smoke.py` — version sync. - -## Tooling - -- `uv` for everything. Don't use `pip` directly. -- Pyright **strict mode** is enforced (`pyproject.toml`). Annotations are not optional. -- Ruff for lint + format. Pre-commit hook runs `ruff format` automatically — the file you committed may not be the file in the next diff. -- `pytest-asyncio` with `asyncio_mode = "auto"` — `async def test_...` works with no decorator. - -## Common commands - -```bash -uv run pytest -q # all tests -uv run pytest tests/conformance/ -v # spec conformance only -uv run ruff check . && uv run ruff format # lint + format -uv run pyright src/ tests/ # type check -``` - -## Engine design notes that are easy to miss - -- `State` is `frozen=True` AND `extra="forbid"`. Nodes that return an undeclared field surface as a `state_validation_error`, not a silent drop. -- Conditional edges over-approximate at compile time (a conditional from node X is treated as reaching every node), so the unreachable-node check is sound but not tight. -- Each node has exactly one outgoing edge. Branching is via conditional edges, not multiple statics. -- `END` is a distinct sentinel object, not a reserved string. Use the exported `END` constant. +See AGENTS.md. diff --git a/README.md b/README.md index 74064c70..37ec4fa8 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,14 @@ Python reference implementation of [OpenArmature](https://github.com/LunarComman ## Install ```bash -pip install openarmature # core -pip install 'openarmature[otel]' # with OpenTelemetry observability +uv add openarmature # core +uv add 'openarmature[otel]' # with OpenTelemetry observability +# or, with pip: +pip install openarmature +pip install 'openarmature[otel]' ``` -Or for editable local development: +For editable local development: ```bash uv add --editable /path/to/openarmature-python diff --git a/docs/RELEASING.md b/docs/RELEASING.md deleted file mode 100644 index 35d61a07..00000000 --- a/docs/RELEASING.md +++ /dev/null @@ -1,184 +0,0 @@ -# Releasing openarmature - -The release pipeline is tag-driven. Pushing a tag triggers -`.github/workflows/release.yml`, which builds the package and either uploads -to TestPyPI (pre-release tags) or PyPI plus a GitHub Release (final tags). - -## Prerequisites (one-time, already done) - -- Trusted Publisher pending entries on test.pypi.org and pypi.org for project - `openarmature`, owner `LunarCommand`, repo `openarmature-python`, workflow - `release.yml`. Environments: `testpypi` and `pypi`. -- GitHub repo Environments: `testpypi` (no protection) and `pypi` (required - reviewer). The `pypi` reviewer protection is what makes the publish-pypi job - pause for a manual approval click on the Actions tab before any real-PyPI - upload — keep it on. - -## Where the version lives - -Four places stay in sync. A version bump touches all of them; pre-commit -regenerates `uv.lock` automatically when you stage `pyproject.toml`. - -| File | Field | Notes | -|---|---|---| -| `pyproject.toml` | `[project] version` | Source of truth for the build | -| `src/openarmature/__init__.py` | `__version__` | Read at import time | -| `tests/test_smoke.py` | `assert __version__ == ...` | Drift guard | -| `uv.lock` | `[[package]] openarmature version` | Regenerated by pre-commit | - -`__spec_version__` and `tool.openarmature.spec_version` are separate pins for -the spec submodule; per the phased-rollout strategy, they're bumped only at -release prep, not per-phase. - -## Release flow - -### 1. Bump to an RC version - -On main, in a small "release: prep" PR: - -```bash -# Pick the version you intend to ship; suffix `rcN` for the first RC. -vim pyproject.toml # version = "0.5.0rc1" -vim src/openarmature/__init__.py # __version__ = "0.5.0rc1" -vim tests/test_smoke.py # match -# uv.lock auto-updates via pre-commit when you stage pyproject.toml -git add pyproject.toml src/openarmature/__init__.py tests/test_smoke.py uv.lock -git commit -m "release: prep 0.5.0rc1" -git push origin -gh pr create --title "release: prep 0.5.0rc1" ... -``` - -If this is the same release cycle as a previous RC, also bump the spec pins -on the same PR if Phase 6's spec target requires it (`spec_version` in -`pyproject.toml`, `__spec_version__` in `__init__.py`). - -Merge the PR. - -### 2. Tag the RC - -Two version forms appear in this guide and they're not interchangeable -in writing even though PEP 440 normalizes them: `pyproject.toml` / -`__init__.py` use `0.5.0rc1` (no hyphen — the canonical PEP 440 -form); git tags + the release-workflow's tag-shape regex use -`v0.5.0-rc1` (with hyphen). Mixing them in commit messages or PR -titles muddies the audit trail; stick to the no-hyphen form for -version-string fields and the hyphenated form for git tags. - -```bash -git checkout main && git pull --ff-only -git tag v0.5.0-rc1 -git push origin v0.5.0-rc1 -``` - -The Release workflow fires. Job order: `test → build → publish-testpypi`. -`publish-pypi` and `github-release` skip because the tag contains `-`. - -Watch the run: `gh run watch $(gh run list --workflow=release.yml --limit=1 --json databaseId --jq '.[0].databaseId') --exit-status` - -### 3. Verify the RC install - -In a fresh venv: - -```bash -python3 -m venv /tmp/oa-verify -/tmp/oa-verify/bin/pip install \ - --index-url https://test.pypi.org/simple/ \ - --extra-index-url https://pypi.org/simple/ \ - openarmature==0.5.0rc1 -/tmp/oa-verify/bin/python -c " -import openarmature -from openarmature.graph import GraphBuilder, State, SubscribedObserver -print(f'version: {openarmature.__version__}') -print(f'spec_version: {openarmature.__spec_version__}') -print('imports ok') -" -rm -rf /tmp/oa-verify -``` - -`--extra-index-url` is required: TestPyPI doesn't host all transitive deps -(pydantic, etc.), so pip needs to fall back to real PyPI for them. - -If anything's wrong (broken install, wrong reported version, missing deps, -README rendering issue on TestPyPI), fix on main and bump to `0.5.0rc2`. RC -slots are one-shot — TestPyPI refuses re-uploads of the same version. - -### 4. Tag the real release - -When the RC verifies clean, drop the suffix on a new "release:" PR: - -```bash -vim pyproject.toml # version = "0.5.0" -vim src/openarmature/__init__.py # __version__ = "0.5.0" -vim tests/test_smoke.py # match -git add ... -git commit -m "release: 0.5.0" -gh pr create --title "release: 0.5.0" ... -``` - -Merge it. Then tag from main: - -```bash -git checkout main && git pull --ff-only -git tag v0.5.0 -git push origin v0.5.0 -``` - -Job order: `test → build → publish-pypi → github-release`. The -`publish-pypi` job pauses at the `pypi` environment with a "waiting for -approval" notice. Click approve on the Actions tab; PyPI receives the -upload; then `github-release` creates the public GitHub Release with -auto-generated notes. - -## What the workflow gates on - -| Tag pattern | publish-testpypi | publish-pypi | github-release | -|---|:---:|:---:|:---:| -| `v0.5.0` | skip | run | run | -| `v0.5.0-rc1`, `v0.5.0-rc2`, ... | run | skip | skip | -| `v0.5.0-beta1`, `v0.5.0-alpha1`, ... | skip | skip | skip | - -The `-rc` suffix is the only pre-release form that actually publishes -(to TestPyPI). Any other suffix is a no-op — failsafe against accidental -uploads from misnamed tags. Real PyPI requires a clean SemVer tag with -no suffix at all. - -The workflow also runs a "Verify pyproject.toml version matches tag" -step before the build. PEP 440 normalization is applied via -`packaging.version.Version`, so `0.5.0-rc1` ≡ `0.5.0rc1`. - -## Common failure modes - -**OIDC trust error on first publish.** The pending publisher on TestPyPI -or PyPI hasn't been set up correctly. Re-check the four fields (project, -owner, repo, workflow filename, environment) on the index's "Add a new -pending publisher" form. - -**"version mismatch" failure in the test job.** `pyproject.toml` doesn't -match the pushed tag after PEP 440 normalization. Fix `pyproject.toml`, -make a new commit, and tag again with a fresh RC number — TestPyPI/PyPI -won't accept a re-upload at the existing version. - -**publish-pypi never runs.** Check the tag name. Any `-` in the tag (rc, -beta, alpha, dev) skips real-PyPI by design. Real release requires a -clean `vX.Y.Z` tag with no suffix. - -**Approval gate stuck on `pypi` environment.** That's working as -intended. Click "Review deployments" on the Actions run, approve the -`pypi` environment. - -## Verifying after release - -- **PyPI**: https://pypi.org/project/openarmature/ -- **GitHub Release**: Releases tab on the repo; auto-generated notes - list every commit since the previous tag. -- Smoke install in a fresh venv: - ```bash - pip install openarmature== - python -c "import openarmature; print(openarmature.__version__)" - ``` - -## Future work - -- A first-class `drain` deadline parameter (spec proposal in flight) will - change the engine API; release notes for that version should call it - out as a behavioral change. diff --git a/docs/concepts/checkpointing.md b/docs/concepts/checkpointing.md new file mode 100644 index 00000000..47f9343f --- /dev/null +++ b/docs/concepts/checkpointing.md @@ -0,0 +1,173 @@ +# Checkpointing + +Save state at every node boundary; resume a crashed run from the last +saved point on a subsequent `invoke()`. Without a checkpointer, the +engine holds no state across invocations — a crash means start-from-entry. + +## Wiring a checkpointer + +Register at build time via `with_checkpointer`: + +```python +from openarmature.checkpoint import SQLiteCheckpointer + +checkpointer = SQLiteCheckpointer(db_path="./checkpoints.db") + +graph = ( + GraphBuilder(MyState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge("step_a", "step_b") + .add_edge("step_b", END) + .set_entry("step_a") + .with_checkpointer(checkpointer) + .compile() +) +``` + +The engine writes a record at every `completed` event for outermost- +graph nodes and subgraph-internal nodes. **Fan-out instance internal +events do NOT save** in the shipping version — atomic-restart is the +fan-out contract. + +## Saves are synchronous-by-contract + +The engine **awaits** every `Checkpointer.save` before continuing to +the next node. This is the load-bearing property that makes +checkpointing useful at all: a crash immediately +after a `completed` event cannot have lost the corresponding save, +because the save resolves before the next node runs. + +The corollary: slow backends throttle execution. Wrapping a high- +latency persistence layer in a checkpointer makes the whole graph +run at its latency. Plan accordingly — async writes inside the +backend (e.g., `asyncio.to_thread` around a sync driver) are fine; +fire-and-forget patterns that return before durability is established +violate the contract. + +## Resuming + +Pass `resume_invocation` to `invoke()`: + +```python +final = await graph.invoke(initial_state, resume_invocation="") +``` + +- If a record exists for that `invocation_id`, the engine restores + state from `record.state` (or `parent_states` chain for subgraph- + internal resumes), reconstructs the completed-node set from + `record.completed_positions`, and continues from the first not-yet- + completed node. +- **If no record exists, the engine raises `CheckpointNotFound`.** + It does NOT silently start a fresh run; the user must explicitly + handle the not-found case (typically: drop the + `resume_invocation=` and re-invoke without it for a fresh start). + +`CheckpointRecordInvalid` surfaces when a record's `schema_version` +doesn't match the current `CHECKPOINT_SCHEMA_VERSION`, or when its +persisted state can't be re-validated against the current state class +(state-shape mismatch after a refactor). + +## What a CheckpointRecord carries + +```python +@dataclass(frozen=True) +class CheckpointRecord: + invocation_id: str + correlation_id: str + state: Any + completed_positions: tuple[NodePosition, ...] + parent_states: tuple[Any, ...] + last_saved_at: float + schema_version: str = CHECKPOINT_SCHEMA_VERSION + fan_out_progress: None = field(default=None) +``` + +Field framing worth getting right: + +- **`completed_positions` is history, not "next."** It's the list of + `NodePosition`s that have already completed. Resume works by + *replaying that list to derive the next node*, not by reading a + pointer to the next node. This is why the field is plural and why + the framing matters: every saved node contributes a position, and + resume walks the graph skipping every position that's already + there. +- **`correlation_id` ≠ `invocation_id`.** `invocation_id` identifies + *this* graph run uniquely. `correlation_id` is a cross-system + identifier propagated via ContextVar — multiple invocations + related by a higher-level request can share one `correlation_id` + while each having its own `invocation_id`. See + [Observability](observability.md) for how `correlation_id` + threads through logs and spans. +- **`parent_states` is the chain of containing-graph snapshots.** + Outermost first; empty for an outer-level save. Inner-node saves + populate it so resume can re-enter a subgraph from the right + depth without re-projecting. +- **`fan_out_progress: None` is reserved** for a future per-instance + fan-out resume mode (planned, not yet shipped). In the shipping + version it's always `None`. + +## The Checkpointer Protocol + +Four methods: + +```python +class Checkpointer(Protocol): + async def save(self, invocation_id: str, record: CheckpointRecord) -> None: ... + async def load(self, invocation_id: str) -> CheckpointRecord | None: ... + async def list(self, filter: CheckpointFilter | None = None) -> Iterable[CheckpointSummary]: ... + async def delete(self, invocation_id: str) -> None: ... +``` + +- **`save`** — persist the record under `invocation_id`. Durable for + any backend that documents durability. Synchronous-by-contract per + the section above. +- **`load`** — return the *most recent* record for `invocation_id`, + or `None`. Round-trip-stable with what `save` wrote. +- **`list`** — enumerate saved invocations, optionally filtered by + `CheckpointFilter` (currently a single `correlation_id` field; v1 + ships intentionally narrow). +- **`delete`** — remove all records for `invocation_id`. No-op if the + invocation has no record (no error). + +Backends MUST be safe to share across concurrent invocations; the +engine doesn't serialize access. For backends with sync I/O, the +standard pattern is `asyncio.to_thread` around the actual driver +call. + +## Two built-in backends + +- **`InMemoryCheckpointer`** — backed by a dict in process memory. + Loses everything on process exit. Useful for tests and short-lived + contexts that want the API surface without disk overhead. +- **`SQLiteCheckpointer`** — backed by a SQLite database file. + Survives process exit. Reasonable default for any non-trivial use. + +Custom backends just implement the four-method Protocol. Targets that +make sense: Redis (ephemeral, network-shared), Postgres (durable, +multi-process), S3 (cross-region durability). For event-sourced +runtimes (Temporal, DBOS, Restate, Inngest) the Protocol is the +adapter layer. + +## When NOT to use checkpointing + +- **Pure pipelines that complete in seconds.** Restart-from-entry is + cheap; checkpoints are pure overhead. +- **Pipelines whose external side effects can't safely be re-played.** + If node A sends an email, resuming from after A means the email + has already sent — fine if your downstream is idempotent, surprising + if it isn't. Reason explicitly about replay semantics before turning + on resume. + +## What checkpointing is NOT + +- **Not a database.** It's a serialization/deserialization seam for + state, not a query layer. Don't drive analytics off saved records; + emit observability events instead. +- **Not human-in-the-loop.** Pausing for human input is a separate + capability; checkpointing is just "save and resume," not "pause and + wait." +- **Not a workflow orchestrator.** Long-running, multi-process, + cross-system orchestration belongs at a higher layer (Temporal, + Airflow). Checkpointing is for crash-recovery and resumability + within one logical run. diff --git a/docs/concepts/composition.md b/docs/concepts/composition.md new file mode 100644 index 00000000..9d3251c1 --- /dev/null +++ b/docs/concepts/composition.md @@ -0,0 +1,279 @@ +# Composition: conditional edges, subgraphs, projection + +Three composition mechanisms turn a linear pipeline into a routed +pipeline of reusable sub-pipelines: + +1. **Conditional edges** route based on state. +2. **Subgraphs** encapsulate a sub-pipeline as a single node. +3. **Projections** translate state across the subgraph boundary. + +None of these add new primitives — a conditional edge is still one +outgoing edge, a subgraph is still a single node — but they change +what a graph can express. + +## Conditional edges + +A conditional edge is the one outgoing edge from a node whose +*target* is computed from state. You register a sync function that +receives the post-merge state and returns a node name or `END`: + +```python +from openarmature.graph import END, EndSentinel, State + + +class S(State): + route: str = "" + + +def route_from_classification(s: S) -> str | EndSentinel: + if s.route == "research": + return "research" + return "quick_answer" + + +# builder.add_conditional_edge("classify", route_from_classification) +``` + +**Routing decisions belong in state.** Notice that `classify` writes +its decision into a regular state field (`route`) and the edge fn just +reads it. This is deliberate. + +Compared to "classify returns the next node name, the engine stashes +it somewhere invisible," state-driven routing gives you: + +- **Visibility.** The decision is a typed field on `State`. It shows + up in the final state, in `recoverable_state` on crash, in any + trace/logs your nodes emit. +- **Inspectability.** Downstream nodes can *read* `s.route` too. If a + branch wants to know "how did we get here?", it doesn't have to + reconstruct the answer. +- **Testability.** The routing function is a pure `state → string` + call. Test it without touching the graph or any LLM. + +**Why sync?** Conditional edges are routing decisions, not units of +work. If you want `async def`, the right move is to do the IO in the +producing node and write the decision to a state field — exactly what +`classify` does. Keeping edges sync keeps the loop simple to read: +node (async) → merge → edge (sync) → next. + +**Timing.** The edge fn sees state *after* the source node's update +has been merged and re-validated. So `s.route` is whatever `classify` +just wrote (or the prior default, if `classify` didn't touch it). + +**Failure modes:** + +- **Edge fn raises** → `EdgeException`, carries `recoverable_state`. +- **Edge fn returns something that isn't a declared node name or + `END`** → `RoutingError`, carries `recoverable_state` and the bad + return value. + +**Default-branch patterns:** + +- *Permissive fallback*: `return "happy" if cond else "fallback"`. Every + state value routes somewhere. +- *Halt on unknown*: `return "happy" if cond else END`. If the + classifier misbehaves, the graph stops cleanly. +- *Route to an error node*: send unexpected states to a real node that + can log/enrich/halt. Useful when you want the error path to be as + observable as the happy path. + +## Subgraphs + +A subgraph is a `CompiledGraph` used as a node inside another graph. +From the outer graph's point of view it behaves like any other node: +one name, one outgoing edge, receives state / returns partial update. + +```python +research_subgraph = ( + GraphBuilder(ResearchState) + .add_node("plan", plan_research) + .add_node("gather", gather) + .add_node("synthesize", synthesize) + .add_edge("plan", "gather") + .add_edge("gather", "synthesize") + .add_edge("synthesize", END) + .set_entry("plan") + .compile() +) + +# In the outer graph: +builder.add_subgraph_node("research", research_subgraph, projection=...) +``` + +**Encapsulation and reuse:** + +- **Encapsulation.** The outer graph knows that `research` produces an + answer. It doesn't know about `plan`, `gather`, or `synthesize`. If + the research pipeline gains a `verify` step, only the subgraph + changes; outer wiring is untouched. +- **Reuse.** The compiled subgraph is a plain Python value. You can + `await research_subgraph.invoke(...)` directly in a test, drop it + into a *different* outer graph, or compose it inside yet another + subgraph. + +**Separate state schemas are load-bearing.** The subgraph has its own +`State` subclass, distinct from the parent's. At compile time, the +subgraph's reducer table and field validation are built against its +own schema. Parent fields can't leak in by accident — they aren't in +scope on either side of the boundary. **The only way data crosses is +through the projection.** + +**When to reach for one.** Two signals: + +- The inner steps form a cohesive sub-computation with its own state + shape (e.g., research nodes need `angles` / `notes` / `answer`; the + outer graph doesn't care). +- You want those inner steps to be reusable or testable in isolation. + +If neither applies, inline nodes are simpler. Don't add a subgraph +boundary just to have one. + +## Projection: the parent ↔ subgraph data seam + +A `ProjectionStrategy` is the translation layer at the boundary. It +decides **what the subgraph sees on entry** and **what leaks back out +on exit**. It's a Protocol with two methods: + +```python +class ProjectionStrategy(Protocol): + def project_in(self, parent_state: State, subgraph_state_cls: type[State]) -> State: ... + def project_out( + self, + subgraph_final_state: State, + parent_state: State, + subgraph_state_cls: type[State], + ) -> Mapping[str, Any]: ... +``` + +Three strategies cover most cases. + +### `FieldNameMatching` (the default) + +If you don't pass a `projection=` argument, you get this. It behaves +asymmetrically: + +- **`project_in`: parent state is ignored.** Returns + `subgraph_state_cls()` — a fresh instance from the subgraph's + defaults. If the subgraph has a required field, this constructor + fails; the subgraph can't run without an explicit projection. +- **`project_out`: field-name intersection.** Looks at the subgraph's + final state, keeps fields whose names also exist on the parent, and + returns them as a partial update. The parent's reducers then merge. + +The asymmetry — "closed on the way in, open on the way back" — is by +design. The author opts *in* to sharing data with the subgraph; the +subgraph's observable outputs route back through the parent's reducers +automatically. + +In practice, the default is rarely enough. + +### `ExplicitMapping` (declarative) + +When the projection is "copy parent.foo into subgraph.bar on the way +in, write subgraph.baz back as parent.qux on the way out," reach for +`ExplicitMapping`: + +```python +from openarmature.graph import ExplicitMapping + +projection = ExplicitMapping[ParentState, SubgraphState]( + inputs={"topic": "topic_a"}, # subgraph_field: parent_field + outputs={"a_summary": "summary", "a_score": "score"}, # parent_field: subgraph_field +) +builder.add_subgraph_node("analyze_a", subgraph, projection=projection) +``` + +`inputs` and `outputs` are independent — pass either, both, or neither. + +**Asymmetry — inputs additive, outputs replacement.** This mirrors the +default's asymmetry. + +- `inputs` is *additive over no-projection-in*. Subgraph fields named + in `inputs` get the corresponding parent field's value; unnamed + fields get their schema defaults. +- `outputs` *replaces* field-name matching when present. Only pairs + named in `outputs` are merged back. Unnamed subgraph fields are + discarded — no slip of extra fields by accident. + +**`None` vs `{}` for `outputs`:** + +- `outputs=None` (absent) → fall back to field-name matching for + project-out. Useful when you want precise inputs but the default's + output behavior. +- `outputs={}` (empty) → project nothing back. Useful for + fire-and-forget subgraphs whose results you intentionally drop. + +**Compile-time validation.** `ExplicitMapping.validate` runs at +parent-graph compile and raises `MappingReferencesUndeclaredField` if +any mapping names a field that isn't on the relevant schema. +Refactor-safe — if you rename a parent field but forget the mapping, +construction fails, not runtime. + +**The case `ExplicitMapping` uniquely unlocks.** Same subgraph at +multiple sites with disjoint parent fields: + +```python +analysis = build_analysis_subgraph() # one CompiledGraph + +builder.add_subgraph_node( + "analyze_a", + analysis, + projection=ExplicitMapping[ComparisonState, AnalysisState]( + inputs={"topic": "topic_a"}, + outputs={"a_summary": "summary", "a_score": "score"}, + ), +) +builder.add_subgraph_node( + "analyze_b", + analysis, + projection=ExplicitMapping[ComparisonState, AnalysisState]( + inputs={"topic": "topic_b"}, + outputs={"b_summary": "summary", "b_score": "score"}, + ), +) +``` + +The two sites address disjoint parent fields, so they cannot collide. +Without explicit mapping, both calls would have to read from and write +to the same parent fields under name matching — making "run the same +subgraph twice on different inputs" structurally impossible. + +### Custom projection strategies + +If you need behavior beyond name-mapping — synthesize values, project +conditionally, transform on the way through — write a class that +matches the Protocol: + +```python +class QuestionProjection: + def project_in(self, parent_state, subgraph_state_cls): + return subgraph_state_cls(question=parent_state.question) + + def project_out(self, subgraph_final_state, parent_state, subgraph_state_cls): + return { + "answer": subgraph_final_state.answer, + "trace": subgraph_final_state.trace, # appends into parent's trace + "tallies": {"research_runs": 1}, # merges into parent's tallies + } +``` + +Then `.add_subgraph_node("research", research_subgraph, +projection=QuestionProjection())`. + +A few design points worth sitting with: + +- **`project_out` returns a partial update, not a full state.** The + parent's reducers apply: `trace` appends, `tallies` merges, `answer` + is `last_write_wins`. Clean composition without thinking about it. +- **Unknown fields from `project_out` raise.** Parent's `extra="forbid"` + catches typos at the merge boundary. +- **The `parent_state` argument of `project_out` is for context, not + for writing.** You can read it to decide what to project — "only + return the answer if the parent was in a research route" — but you + can't mutate it. + +`ProjectionStrategy` is a `Protocol`, not a base class. A class fits +the shape or it doesn't; the type checker verifies at use sites. If +you have Java instincts ("where's the `implements` keyword?"), reach +for TypeScript or Go interface instincts instead — that's the same +family. diff --git a/docs/concepts/fan-out.md b/docs/concepts/fan-out.md new file mode 100644 index 00000000..bbb3dce0 --- /dev/null +++ b/docs/concepts/fan-out.md @@ -0,0 +1,163 @@ +# Fan-out + +Run the same subgraph many times in parallel, each instance receiving +a different input, results merged back deterministically. + +The "same subgraph at two-or-three call sites" pattern from +[`ExplicitMapping`](composition.md#explicitmapping-declarative) +handles cases where you know the parent fields up front. Fan-out +handles N call sites where N is determined at runtime — "for each +item in `state.urls`, run the scraping subgraph; collect the +results." + +## Two modes: per-item or per-count + +A fan-out can dispatch instances driven by a list in state +(`items_field` mode) or by a count resolved from state (`count` mode). + +**`items_field` mode** — one instance per item in a parent list field: + +```python +from openarmature.graph import FanOutConfig, FanOutNode + +scrape_all = FanOutNode( + name="scrape_all", + config=FanOutConfig( + subgraph=scrape_subgraph, # CompiledGraph[ScrapeState] + items_field="urls", # parent list field — one instance per item + item_field="url", # subgraph field that receives each item + collect_field="content", # subgraph field whose value is collected + target_field="contents", # parent list field that receives the collection + concurrency=4, + error_policy="fail_fast", # or "collect" + on_empty="raise", # or "noop" + ), +) +builder.add_node("scrape_all", scrape_all) +``` + +**`count` mode** — fixed-or-dynamic instance count, no list field: + +```python +fan_out = FanOutNode( + name="sample", + config=FanOutConfig( + subgraph=sample_subgraph, + count=8, # int or callable: state -> int + collect_field="reading", + target_field="readings", + concurrency=4, + ), +) +``` + +Both `count` and `concurrency` accept a callable that takes the +pre-fan-out parent state and returns an int (`None` for `concurrency` +means unbounded). That lets you size the dispatch from state at run +time. + +## Per-instance state, inputs and outputs + +Each instance gets its own subgraph state — distinct from siblings, +distinct from the parent. By default the instance receives only: + +- the dispatched item in the field named by `item_field` (in + `items_field` mode); and +- the parent-field-name-mapped values declared in `inputs`. + +`inputs` is a `Mapping[subgraph_field, parent_field]`. The subgraph +fields not named in `inputs` (and not `item_field`) take their +schema defaults — same closed-by-default-on-the-way-in posture as +the explicit-projection story for ordinary subgraphs. + +On exit, each instance's `collect_field` value becomes one element +of the parent's `target_field` list, in instance-index order. To +collect additional per-instance fields, declare +`extra_outputs: Mapping[parent_field, subgraph_field]` — each becomes +its own parent list of the same length, instance-index-aligned. + +## Error policy + +Two values: + +- **`"fail_fast"`** (default) — the first instance failure cancels + the in-flight siblings (`asyncio.gather` semantics) and propagates + as a `NodeException` wrapping the failing instance's cause, with + `recoverable_state` set to the parent's pre-fan-out snapshot. Use + this when one bad result invalidates the rest. +- **`"collect"`** — instance failures are captured; the fan-out runs + to completion. Failed instances contribute nothing to + `target_field`. If you declare `errors_field` on the config, each + failed instance produces a record (`{"fan_out_index": str(idx), + "category": str}`) appended to that parent list field. + +Choose by whether partial results are useful. + +## What ends up in the parent + +After the fan-out completes, the parent receives a partial update +containing: + +- `target_field` — list of `collect_field` values, instance-index order. +- Each parent name in `extra_outputs` — list of values from the named + subgraph field, instance-index order. +- `count_field` (if configured) — the instance count. +- `errors_field` (if configured, `"collect"` policy only) — per-instance + error records. +- `on_empty="noop"` for an empty items_field → all the above with empty + lists; `count_field` set to 0. + +## Empty fan-outs + +If `items_field` is set and the parent list is empty (or `count` +resolves to 0): + +- `on_empty="raise"` (default) — raises `FanOutEmpty` (a runtime + error category). +- `on_empty="noop"` — emits an empty partial (no instances dispatched, + no errors). + +## Observability per instance + +The fan-out node's own `started` / `completed` events carry a +`fan_out_config` payload populated from the resolved +`item_count` / `concurrency` / `error_policy` / `parent_node_name`. + +Per-instance events have `fan_out_index = N` (0-based) and a +namespace whose final element is the fan-out node's name — instances +do NOT contribute a separate synthetic namespace element. Backends +disambiguate per-instance spans using `fan_out_index` alongside the +namespace. + +## Resume semantics + +A fan-out node's `completed` event triggers a save like any other +outermost-graph or subgraph-internal node. **Per-instance internal +events do NOT save** in the shipping version — on resume, the +fan-out re-runs end-to-end if it hadn't completed (atomic restart). + +A per-instance fan-out resume mode is planned but not yet shipped. +The `fan_out_progress` field on `CheckpointRecord` is reserved for +its eventual contents. Until it lands, atomic restart is the +shipping behavior. + +## When to reach for fan-out + +The signal: N similar pieces of work, N depends on state at runtime +(not at build time), the work is independent enough to run +concurrently. If N is known at build time and small (≤3), +`ExplicitMapping` at multiple subgraph sites is simpler. If the +work isn't independent — instance 2 needs instance 1's output — +that's a linear pipeline, not fan-out. + +## What fan-out is NOT + +- **Not a map-reduce.** No reduce phase beyond the parent's + reducers. If you need a real reduce, do it in a node *after* the + fan-out. +- **Not a queue.** All instances dispatch within a single + invocation; the engine doesn't persist them. +- **Not retry.** If an instance fails and you want a retry, + wrap the subgraph (or individual nodes inside it) with retry + middleware. The fan-out's `error_policy` is a fan-in-collection + decision, not a recovery one. diff --git a/docs/concepts/graphs.md b/docs/concepts/graphs.md new file mode 100644 index 00000000..cad20e3c --- /dev/null +++ b/docs/concepts/graphs.md @@ -0,0 +1,199 @@ +# Graphs: nodes, edges, build, invoke + +Four moves turn a state schema into a runnable pipeline: + +1. Write **node functions** that read state and return partial updates. +2. Wire them together with **edges** through `GraphBuilder`. +3. **Compile** the builder into an immutable graph. +4. **Invoke** the compiled graph to run it. + +## Nodes are async functions + +A node is just an async callable with this shape: + +```python +from collections.abc import Mapping +from typing import Any +from openarmature.graph import State + + +class S(State): + plan: str = "" + + +async def my_node(state: S) -> Mapping[str, Any]: + return {"plan": "outline"} +``` + +Three things to notice: + +- **Read the snapshot, return a partial update.** The node doesn't + construct a new state, doesn't mutate the old one, doesn't worry + about merging. The engine handles all of that. +- **The return is a `Mapping`, not a `dict` literally.** You can return + any dict shape that satisfies the type; field names are validated + against the state schema at merge time (extra keys raise). +- **Empty dict is fine.** `return {}` means "I made no state changes" — + state passes through, execution moves on per the outgoing edge. Good + for logging or pure-observation nodes. + +**Why async?** The canonical node does IO — LLM call, HTTP request, +tool invocation. An async signature lets the runtime overlap IO when +you eventually add parallel branches or retries. For a purely CPU node, +async costs nothing — you just `return {...}` without an `await`. + +You register the node on a builder under a name: + +```python +builder.add_node("plan", plan_node) +``` + +The name is what edges reference. The function itself is the work. + +## Edges: exactly one outgoing per node + +Each node has **exactly one** outgoing edge. Branching is *not* +expressed with multiple static edges from the same source; it's a +single *conditional* edge whose function chooses the next node. (More +on conditional edges in [Composition](composition.md).) + +A static edge is unconditional: + +```python +builder.add_edge("plan", "write") # after `plan` merges, run `write` +builder.add_edge("write", END) # after `write` merges, halt +``` + +`END` is a sentinel object — a distinct value, not the string `"END"`: + +```python +from openarmature.graph import END +``` + +Using the literal string `"END"` is fine *as a node name* if you want +one; the sentinel is a separate object so the engine can tell them +apart. + +**Why one outgoing edge per node?** It concentrates the routing +decision into one place per source (in the case of conditional edges, +the routing function). Scattering routing across multiple static edges +would require some precedence rule. Compile-checking and reading both +get simpler when there's one rule. + +## GraphBuilder is the construction surface + +`GraphBuilder` is mutable. Every method returns `self` so you chain: + +```python +from openarmature.graph import END, GraphBuilder, State + + +class S(State): + plan: str = "" + + +async def plan(_s: S) -> dict[str, str]: + return {"plan": "outline"} + + +graph = ( + GraphBuilder(S) + .add_node("plan", plan) + .add_edge("plan", END) + .set_entry("plan") + .compile() +) +``` + +The methods you'll use: + +- **`GraphBuilder(state_cls)`** — constructor. The state class + determines the reducer table at compile time. +- **`.add_node(name, fn)`** — register an async node function. +- **`.add_edge(source, target)`** — static edge. `target` is a node + name or `END`. +- **`.add_conditional_edge(source, fn)`** — branching edge. `fn(state)` + is sync and returns a node name or `END`. +- **`.add_subgraph_node(name, compiled, projection=None)`** — register + a compiled graph as a node inside this graph (see + [Composition](composition.md)). +- **`.set_entry(name)`** — declare where execution begins. +- **`.compile()`** — validate and return `CompiledGraph`. + +**Why split builder and compiled?** Construction and execution are +different problems. Construction is mutable and permissive (add things +in whatever order reads well); execution wants something immutable and +validated. `compile()` is the one-way door between the two — and +structural problems surface at the door so a bad graph can't reach +runtime. + +## compile() is the structural firewall + +`GraphBuilder.compile()` runs structural checks and raises a +`CompileError` subclass on the first failure. The checks are: + +| Error | When it fires | +| ---------------------------------- | ------------------------------------------------------------------------ | +| `ConflictingReducers` | A field has more than one reducer declared via `Annotated[...]` | +| `NoDeclaredEntry` | `.set_entry(...)` was never called | +| `DanglingEdge` | An edge references a node name that was never `.add_node()`'d | +| `MultipleOutgoingEdges` | A node has more than one outgoing edge | +| `UnreachableNode` | A declared node isn't reachable from the entry via any edge path | +| `MappingReferencesUndeclaredField` | A subgraph projection mapping names a field absent from the schema | + +Every failure here is a graph-shape problem — the kind of thing that +would otherwise crash mid-execution with a confusing traceback. +Catching them at construction means you *cannot* invoke a malformed +graph. + +**Reachability is sound but loose.** Conditional edges over-approximate +during the reachability check: a conditional from node X is treated as +reaching every declared node (the compiler can't statically know the +function's range). So a node reachable only via a never-taken branch is +still considered reachable. No false positives; some slack on the +upper bound. + +## invoke() runs the loop + +`CompiledGraph.invoke()` runs to completion and returns the final +state: + +```python +final = await graph.invoke(S()) +``` + +The per-step loop: + +1. Dispatch the `started` observer event for the current node. +2. Run the current node, await its result. +3. Merge its partial update into state via per-field reducers. +4. Re-validate state against the schema. +5. Evaluate the outgoing edge against the *post-merge* state to pick + the next node (or `END`). +6. Dispatch the `completed` observer event — populating `post_state` + if the step succeeded, or `error` if any of steps 2–5 failed + (including edge / routing errors, which attach to the preceding + node's `completed` event rather than producing a new one). + +The output is the final `State` instance — whatever state looks like +when an edge returns `END`. See [Observability](observability.md) for +what observers do with the started/completed pair. + +## Runtime errors carry context + +If a node, reducer, edge function, or routing decision fails, the +engine raises one of these: + +| Error | When it fires | `recoverable_state`? | +| ---------------------- | ------------------------------------------------------------------- | :------------------: | +| `NodeException` | A node function raised | yes | +| `ReducerError` | A reducer raised (often a type mismatch on the partial) | yes | +| `EdgeException` | A conditional edge fn raised | yes | +| `RoutingError` | Conditional edge returned something that isn't a node name or `END` | yes | +| `StateValidationError` | Merged state fails schema validation (typo'd field, bad type) | no | + +`recoverable_state` is the state at the point of failure — +pre-failing-node for node/edge/routing errors, pre-merge for reducer +errors. Useful for post-crash forensics. State validation errors don't +carry recoverable_state because the merge that triggered the failure +hadn't produced a valid state to recover *to*. diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 00000000..2ff098fd --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,22 @@ +# Concepts + +Each page is a focused take on one idea. Read top-to-bottom for a tour of +the framework, or jump to whichever concept you need. + +- [State and reducers](state-and-reducers.md) — typed state, per-field + merge policies, what makes nodes safe to write. +- [Graphs: nodes, edges, build, invoke](graphs.md) — the four moves you + make to turn a state schema into a runnable pipeline. +- [Composition: conditional edges, subgraphs, projection](composition.md) + — routing decisions, encapsulated sub-pipelines, the parent ↔ subgraph + data seam. +- [Fan-out](fan-out.md) — running the same subgraph many times in + parallel, results merged back deterministically. +- [Observability](observability.md) — node-boundary hooks, OTel mapping, + log correlation. +- [Checkpointing](checkpointing.md) — save state at each node boundary, + resume from a prior point. + +If you're brand-new, [Quickstart](../getting-started/index.md) is the +faster entry — under a minute to a running graph. Come back here when +you want to know *why* things are shaped the way they are. diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md new file mode 100644 index 00000000..1ab3955f --- /dev/null +++ b/docs/concepts/observability.md @@ -0,0 +1,319 @@ +# Observability + +Two complementary patterns: + +- **The `trace` field pattern** — a typed list inside state that nodes + append to. State-shaped history, accessible from inside the graph, + visible in the final state. Falls out of existing primitives. + Covered in [State and reducers](state-and-reducers.md). +- **Observer hooks** — out-of-band events delivered to external code, + with full pre/post state snapshots, error context, and visibility + across subgraph boundaries. The control-side equivalent of the + data-side `trace` field. This page. + +The two are complementary, not redundant. `trace` is what state itself +remembers. Observers are what external code sees as state changes. + +## An observer is an async callable + +```python +from openarmature.graph import NodeEvent + + +async def my_observer(event: NodeEvent) -> None: + print(event.phase, event.step, event.namespace, event.node_name) +``` + +The matching Protocol is `Observer`: + +```python +from openarmature.graph import Observer + + +class StructuredLogger: + async def __call__(self, event: NodeEvent) -> None: ... + + +_: Observer = StructuredLogger() # structural conformance check +``` + +## Two registration modes + +**Graph-attached** — fires on every invocation until removed: + +```python +compiled = builder.compile() +handle = compiled.attach_observer(my_observer) +# ...later +handle.remove() # idempotent +``` + +Changes to the registered set during a graph run don't take effect +until the next invocation — the in-flight observer set is fixed at +`invoke()` time. + +**Invocation-scoped** — fires only for one specific run: + +```python +final = await compiled.invoke(initial, observers=[request_logger]) +``` + +Common pattern: graph-attached for global concerns (Sentry, metrics, +structured tracing); invocation-scoped for per-request concerns (a +request-ID closure, a per-call snapshot ring). + +## The NodeEvent shape + +```python +@dataclass(frozen=True) +class NodeEvent: + node_name: str + namespace: tuple[str, ...] + step: int + phase: Literal["started", "completed", "checkpoint_saved"] + pre_state: State + post_state: State | None + error: RuntimeGraphError | None + parent_states: tuple[State, ...] + attempt_index: int = 0 + fan_out_index: int | None = None + fan_out_config: FanOutEventConfig | None = None +``` + +A walk-through: + +- **`phase`** — every node attempt produces a `started` / `completed` + *pair*. The pair shares `step` and `pre_state`. `started` fires + before the node body runs; `completed` fires after the reducer + merge succeeds *and* the outgoing edge has been evaluated. A + successful pair populates `post_state` on `completed`; a failed + pair populates `error` on `completed`. **`started` events have + neither `post_state` nor `error` populated.** + + `checkpoint_saved` is an additional optional phase: when a + Checkpointer is attached, the engine emits one per successful save + (post-`completed`, immediately after the save resolves). + **Default observer subscriptions don't include `checkpoint_saved`**; + opt in via `phases={"checkpoint_saved"}` when registering (or + `phases=KNOWN_PHASES`, exported from `openarmature.graph`, to + subscribe to every phase including `checkpoint_saved`). + +- **`node_name`** — the node's local name in its immediate containing + graph. For nested subgraphs, the inner name, NOT a qualified path. + +- **`namespace`** — the qualified path of containing-graph node names + + the current node's name, outermost-first. For a top-level node: + `(node_name,)`. For a subgraph-internal node: + `(outer_subgraph_node_name, inner_name)`. A *tuple of strings* — + the framework keeps it as a tuple at the API boundary rather than + joining with a delimiter, so node names can contain any characters + without parsing ambiguity. + +- **`step`** — monotonic counter starting at 0, scoped to one + outermost invocation. Subgraph-internal nodes increment the same + counter; subgraph events interleave with outer events. The + `started`/`completed` pair for one attempt share the same step. + +- **`pre_state`** / **`post_state`** — state the node received vs. + state after the reducer merge. *Shape varies with namespace*: for + a subgraph-internal node, both are subgraph-state instances, not + the outer state. + +- **`error`** — the wrapped runtime error on `completed` events that + failed. `event.error.category` gives the canonical error category; + `event.error.__cause__` gives the original exception. **Edge / + routing errors land here too** — see *Routing errors and the + completed event* below. + +- **`parent_states`** — one snapshot per containing graph, outermost + first. Empty tuple for outermost-graph events. Invariant: + `len(parent_states) == len(namespace) - 1`. + +- **`attempt_index`** — 0-based retry attempt counter. `0` for nodes + not wrapped by retry middleware; `1+` for retries. + +- **`fan_out_index`** — 0-based per-instance index for events inside + a fan-out instance; `None` outside. + +- **`fan_out_config`** — populated on `started` / `completed` events + for the *fan-out node itself*, carrying the resolved + `item_count` / `concurrency` / `error_policy` / `parent_node_name`. + `None` on every other event. + +## Routing errors and the completed event + +When a conditional edge raises or returns an invalid target: + +1. The preceding node runs and its body returns successfully. +2. The reducer merge succeeds. +3. The engine evaluates the outgoing edge. +4. The edge fn raises (`EdgeException`) OR returns something that + isn't a declared node name or `END` (`RoutingError`). +5. The engine populates that error into the preceding node's + `completed` event and dispatches it — sharing the + started/completed pair rather than synthesising a new event. + +So edge / routing errors *do* land on a `NodeEvent` — on the +*preceding* node's `completed` event, with `error` populated and +`post_state` left `None`. Observers see the failure attributed to the +right node without a synthetic event. + +## Subgraph events bubble up + +A subgraph-attached observer sees its own internal node events +whenever the subgraph runs — directly OR as a subgraph inside a +parent. The parent's observers ALSO see those internal events. + +Delivery order for an event from a subgraph-internal node: + +``` +outermost-graph-attached → ... → subgraph-attached → invocation-scoped +``` + +Within each level, registration order. The subgraph-as-node wrapper +itself does *not* generate its own event — it's transparent to +observers. + +## Serial delivery + +Observers receive events serially within a single outermost invocation: + +- No two observers receive the same event concurrently. +- No observer sees event N+1 until every observer has finished N. + +**Why not parallel?** Two reasons. Parallel observers' output +interleaves nondeterministically (log readers can't reconstruct +ordering), and multi-observer error semantics get fiddly +(first-error-wins? collected exceptions?). Serial keeps per-run +output deterministic and error handling trivial. If a single observer +needs internal parallelism it can `asyncio.gather` itself. + +A slow observer holds back delivery of subsequent events to siblings. +Two responses: keep the slow exporter as one observer (it serializes +naturally), or push events to an internal queue and return fast. + +## Async-from-graph delivery + drain() + +The graph's execution loop dispatches events onto a per-invocation +queue and **does not await** observer processing. Event dispatch is +constant-time from the graph's perspective — observers can't slow +node execution down. + +This means `await compiled.invoke(...)` returns when the graph +reaches END (or raises), regardless of whether the observer queue has +finished. For long-running services that's fine. For short-lived +processes (scripts, serverless, CLIs), events dispatched late in the +run may not be delivered before the process exits. + +`drain()` blocks until every dispatched event has been delivered: + +```python +final = await compiled.invoke(initial) +await compiled.drain() +``` + +- Per-graph, not per-invoke. Drain awaits *all* prior invocations' + queues. +- Snapshot at call time. Events from invocations started concurrently + with `drain()` may or may not be included. +- Subgraph events are part of the parent. A parent drain covers every + subgraph event from any of its invocations — no need to drain each + subgraph separately. + +If you forget `drain()` in a CLI, the symptom is an empty trace file +or missing log entries. + +## Error isolation + +An observer that raises: + +- Does NOT propagate its exception to `invoke()`'s caller. +- Does NOT prevent other observers from receiving the same event. +- Does NOT prevent any observer from receiving subsequent events. + +Failures are reported via `warnings.warn` (Python's channel for +non-fatal anomalies). A bad observer can't take down the system that's +calling it. The graph run is the source of truth; observability is a +side concern. + +## correlation_id is a separate join key + +Two identifiers travel with every invocation: + +- **`invocation_id`** — unique per `invoke()` call. Identifies *this + run*. Surfaced on `CheckpointRecord.invocation_id`, observer span + attributes, log records. +- **`correlation_id`** — a cross-system identifier propagated via + `ContextVar`. Multiple invocations related by a higher-level + request (e.g., a parent run that spawns a subgraph via direct + `await sub.invoke(...)`, or a user-request that drives several + related graph runs) can share one `correlation_id` while each + having its own `invocation_id`. + +`correlation_id` is the load-bearing join key in the multi-backend +scenario: a Langfuse trace, an OTel trace, and a structured log all +end up with the same `correlation_id` even +though their `invocation_id`s differ. It's exported from the +`openarmature.observability` package as `current_correlation_id` / +`current_invocation_id` (and friends) for code that needs to thread +the IDs explicitly. + +## OpenTelemetry mapping (opt-in) + +Install with the `[otel]` extra: + +```bash +pip install 'openarmature[otel]' +``` + +`OTelObserver` maps node events to OTel spans + structured log +correlation: + +- Each node `started` / `completed` pair becomes one span. +- Subgraph hierarchy is reflected in span parent-child structure. +- Spec error categories map to OTel `Status.ERROR` with semantic + attributes. +- Log records emitted during node execution carry the active span's + `trace_id` / `span_id` plus an `openarmature.correlation_id` + attribute, so the join key survives the OTel boundary. + +### TracerProvider isolation + +`OTelObserver` constructs a **private** `TracerProvider` from the +processor you supply — it never registers globally and never reads +`get_tracer_provider()`. This isolation is intentional. + +The motivation is concrete: many production stacks already register a +global `TracerProvider` (Langfuse v3's OpenInference integration is +the recurring example) for their own instrumentation. If openarmature +piggybacked on the global provider, every span the engine emits would +also flow to those other backends — doubling exports, corrupting +hierarchies, and tying openarmature's lifecycle to whichever +unrelated library happened to register first. Isolation prevents +that; the observer's spans only flow through the processor you handed +it. + +### Detached trace mode + +Some subgraphs or fan-outs are better as their own root trace than as +descendants of the parent's span tree — long-running asynchronous +work, retries that would balloon a parent span, or work that gets +reported to a different backend. + +Configure detachment on the observer: + +```python +obs = OTelObserver( + processor=processor, + detached_subgraphs=frozenset({"long_async_step"}), + detached_fan_outs=frozenset({"daily_batch"}), +) +``` + +A detached subgraph or fan-out gets a fresh trace root (new +`trace_id`); the `correlation_id` still propagates through, so +join semantics survive even when trace boundaries don't. + +The non-detached default is what you want most of the time — one +trace per outermost invocation, with subgraphs and fan-out instances +as nested spans. diff --git a/docs/concepts/state-and-reducers.md b/docs/concepts/state-and-reducers.md new file mode 100644 index 00000000..95ad73b1 --- /dev/null +++ b/docs/concepts/state-and-reducers.md @@ -0,0 +1,166 @@ +# State and reducers + +## State is a typed, frozen Pydantic model + +The graph is a pipeline of pure state transitions. Each node receives a +snapshot of state, returns a *partial update* (a dict of just the fields +it wants to change), and the engine merges the update via per-field +reducers. The engine is the only thing that writes to state. + +The shape of state is your responsibility. You subclass `State`: + +```python +from typing import Annotated +from openarmature.graph import State, append +from pydantic import Field + + +class GraphState(State): + topic: str + plan: str = "" + output: str = "" + trace: Annotated[list[str], append] = Field(default_factory=list) +``` + +The `State` base class is a pre-configured Pydantic `BaseModel`. Two +guarantees come baked in: + +- **Frozen.** `model_config = ConfigDict(frozen=True, ...)`. A node + can't `state.plan = "..."` even if it tried; the assignment raises. +- **No extra fields.** `extra="forbid"`. A node that returns + `{"plann": "..."}` (typo) fails loudly with a `StateValidationError` + instead of silently dropping the key. + +Everything else Pydantic gives you — validators, computed fields, +custom types, `Field` metadata — still works. You don't need to set +`model_config` yourself; subclassing `State` is enough. + +**Why frozen?** It rules out a whole class of bugs that make multi-step +LLM pipelines miserable: the snapshot a node holds can't be mutated by +anything else while it's running. State changes are an *engine action* +(the merge), not a *node action*. The node's job is "produce this +update"; merging is somebody else's problem. + +**Why `extra="forbid"`?** Field-name typos are common during iteration. +Failing loudly at the merge boundary means a typo can't quietly produce +a graph that runs but produces the wrong output. + +## State does NOT have history + +The engine doesn't retain prior state snapshots. `CompiledGraph.invoke()` +holds one `state` local; each merge reassigns it, and the previous +snapshot becomes unreferenced. + +```text +state = initial_state +while not at END: + partial = await current_node(state) + state = merge(state, partial) # prior state now unreferenced + current_node = next_node_for(state) +``` + +This is by design. Checkpoint/resume, per-node streaming, persistent +state backends, and human-in-the-loop interrupts are explicit +non-goals for the engine itself. They're pipeline-layer utilities +that compose *on top of* the graph primitives. Keeping the engine +one-job keeps it small. + +What you do have for "what happened": + +- A **user-built trail inside state.** Idiomatic: an + `Annotated[list[str], append]` field that nodes write to. Whatever + your schema captures, that's the history you get after `invoke()` + returns. +- **Crash context.** The four non-validation runtime errors + (`NodeException`, `EdgeException`, `ReducerError`, `RoutingError`) + carry a `recoverable_state` — the state at the point of failure. Good + for forensics; not a walkable timeline. + +If you need a full timeline (debugging, eval, time-travel, +resumability), build it explicitly: fatter `trace`, logging middleware, +or use [Checkpointing](checkpointing.md). + +## Reducers: one per field + +A reducer is a named callable: `(prior, partial) -> new`. Attach one to +a field via `typing.Annotated`: + +```python +from typing import Annotated +from openarmature.graph import State, append +from pydantic import Field + + +class GraphState(State): + trace: Annotated[list[str], append] = Field(default_factory=list) +``` + +On each merge step, the engine looks up the reducer for every updated +field and calls `reducer(prior_value, partial_value)`. Fields without +an annotated reducer fall back to `last_write_wins`. + +**The point of per-field reducers:** a node shouldn't know how its +output combines with prior state — that's a property of the field, not +the node. `trace.append`, `meta.merge`, `score.last_write_wins`. The +schema declares the policy once; nodes return their increment; the +engine applies the merge consistently. If two nodes write the same +field and the merge strategy is wrong, the fix is one line on the +schema, not surgery across call sites. + +## Three built-in reducers + +| Reducer | Semantics | Typical use | +| ----------------- | ---------------------------------------- | ------------------------------------- | +| `last_write_wins` | `partial` replaces `prior` *(default)* | Scalars owned by a single node | +| `append` | `[*prior, *partial]` for list fields | Traces, message history, accumulators | +| `merge` | `{**prior, **partial}` (shallow) | Metadata bags, namespaced state | + +```python +from openarmature.graph import append, last_write_wins, merge +``` + +You can write your own. A reducer is any named callable matching the +`(prior, partial) -> new` contract. + +## How reducers execute + +A reducer **always returns a new value** — never mutates `prior`. That +matches the frozen-state contract: the prior list/dict may still be a +snapshot somebody else holds. + +The built-ins type-check their inputs before running. If a node returns +a string for a `list`-typed field, `append` raises `TypeError` before +the bad value can land in state; the engine wraps it as a +`ReducerError` carrying the field name, reducer name, and producing +node. + +Side note: `append`'s name is a bit misleading. It's list +*concatenation* (`[*prior, *partial]`), not `list.append`. It can't +mutate for the same reason `State` is frozen. + +## Return the increment, not the full value + +For reducer-tracked fields, a node returns *only what it's adding*: + +```python +async def plan_node(s: GraphState) -> dict[str, list[str]]: + return {"trace": ["plan"]} # add ["plan"] to trace +``` + +NOT `{"trace": s.trace + ["plan"]}` — that's already what `append` +does. Returning the full list would concatenate twice and duplicate +entries. + +## Two reducers on one field → compile error + +You *can* try to declare two reducers on a field: + +```python +class Bad(State): + log: Annotated[list[str], append, merge] = Field(default_factory=list) +``` + +But `GraphBuilder.compile()` fails with `ConflictingReducers("log")` — +the graph never compiles, so you can't reach runtime with an ambiguous +merge policy. The same compile pass picks the one declared reducer per +field; with no declaration, the default is `last_write_wins`. diff --git a/docs/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 00000000..098bbf66 --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,5 @@ +# Contributing + +Contributor-facing docs (development setup, repo conventions, etc.) +land here. Stub for now — populated as concrete contributor-facing +material accrues. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 00000000..61d5f3fc --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,77 @@ +# Quickstart + +Build and run a two-node graph in under a minute. No LLM required — this is +the smallest possible openarmature program so you can see every part of the +shape on one screen. + +## Install + +```bash +uv add openarmature +# or, with pip: +pip install openarmature +``` + +Requires Python ≥ 3.12. + +## A minimal graph + +Two nodes (`hello` → `world`), one shared field, the `append` reducer. + +```python +import asyncio +from typing import Annotated + +from openarmature.graph import END, GraphBuilder, State, append +from pydantic import Field + + +class S(State): + log: Annotated[list[str], append] = Field(default_factory=list) + + +async def hello(_s: S) -> dict[str, list[str]]: + return {"log": ["hello"]} + + +async def world(_s: S) -> dict[str, list[str]]: + return {"log": ["world"]} + + +graph = ( + GraphBuilder(S) + .add_node("hello", hello) + .add_node("world", world) + .add_edge("hello", "world") + .add_edge("world", END) + .set_entry("hello") + .compile() +) + +final = asyncio.run(graph.invoke(S())) +assert final.log == ["hello", "world"] +``` + +## What just happened + +- **`S`** is the state schema — a frozen Pydantic model. Nodes can't mutate + it; they return partial-update dicts and the engine merges them. +- **`append`** is the reducer attached to `log`. When `hello` returns + `{"log": ["hello"]}`, the engine *appends* to the existing list rather + than replacing it. +- **`add_node` + `add_edge`** declare the graph shape; **`END`** is the + terminal sentinel imported from `openarmature.graph`. +- **`compile()`** runs structural checks at construction time (no dangling + edges, no unreachable nodes, no duplicate reducers) and returns an + immutable `CompiledGraph`. Bad shapes fail here, not at run time. +- **`invoke()`** runs the graph from `set_entry()` to `END` and returns the + final state. + +## Next + +- [Concepts](../concepts/index.md) — deeper on state, reducers, + projections, fan-out, subgraphs, observability. +- [Examples](https://github.com/LunarCommand/openarmature-python/tree/main/examples) + — five runnable demos, each driving a local OpenAI-compatible LLM + endpoint to do real work. +- [API reference](../reference/index.md) — auto-generated from docstrings. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..76db8198 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,71 @@ +--- +hide: + - navigation + - toc +--- + +# OpenArmature + +A workflow framework for LLM pipelines and tool-calling agents — typed +state, structural graph checks, and observability that doesn't require +buy-in from every node. + +[Get started](getting-started/index.md){ .md-button .md-button--primary } +[View on GitHub](https://github.com/LunarCommand/openarmature-python){ .md-button target="_blank" rel="noopener" } + +--- + +
+ +- :material-shield-check:{ .lg .middle }   __Typed, frozen state__ + + --- + + State schemas are Pydantic models with `frozen=True` and + `extra="forbid"`. Nodes can't mutate state — they return partial + updates and the engine merges via per-field reducers. + +- :material-graph:{ .lg .middle }   __Compile-time checks__ + + --- + + Bad graph shapes (dangling edges, unreachable nodes, conflicting + reducers, missing entry) fail at `.compile()`, not at run time. + +- :material-eye:{ .lg .middle }   __Observable, opt-in__ + + --- + + Attach an `Observer` to see every node boundary. Drop in the + optional OTel mapping for spans + log correlation; logs carry + `trace_id` / `span_id` / `correlation_id` automatically. + +- :material-content-save:{ .lg .middle }   __Checkpointable__ + + --- + + In-memory and SQLite `Checkpointer` backends ship in core. Crash at + node N+1, resume from node N's saved state on the next invocation. + +- :material-arrow-split-vertical:{ .lg .middle }   __First-class fan-out__ + + --- + + Per-instance fan-out with bounded concurrency, error-policy choice, + and observability events that attribute correctly per instance. + +- :material-language-python:{ .lg .middle }   __Async-first, LLM-agnostic__ + + --- + + The engine has no concept of LLMs or tools — those live at the node + boundary. Use any provider, any model, any external system. + +
+ +--- + +Built around an open, language-agnostic +[specification](https://github.com/LunarCommand/openarmature-spec). +A TypeScript implementation is on the roadmap; behaviour stays +identical across implementations via spec conformance fixtures. diff --git a/docs/model-providers/authoring.md b/docs/model-providers/authoring.md new file mode 100644 index 00000000..0c63a0f2 --- /dev/null +++ b/docs/model-providers/authoring.md @@ -0,0 +1,201 @@ +# Authoring a Provider + +When you target a wire format that isn't OpenAI Chat Completions — +Anthropic Messages, Bedrock, an internal gateway, a hand-rolled +inference service — implement the `Provider` Protocol yourself. The +shipped `OpenAIProvider` is ~465 lines because it handles every +edge case; a minimum-viable Provider is closer to 60 lines. + +If you're new to Providers, read [Model Providers](index.md) for the +overview and contract guarantees before continuing. + +## Skeleton + +A minimal OpenAI-compatible Provider targeting any +`/v1/chat/completions` endpoint. Compare with +`openarmature.llm.OpenAIProvider` to see what a full implementation +adds (tool-call wire mapping, observability spans, the `/v1/models` +catalog probe, retry-after parsing, lenient argument parsing under +`finish_reason="error"`, etc.). + +```python +from collections.abc import Sequence +from typing import Any + +import httpx +from openarmature.llm import ( + AssistantMessage, + Message, + ProviderInvalidResponse, + ProviderUnavailable, + Response, + RuntimeConfig, + SystemMessage, + Tool, + ToolMessage, + Usage, + UserMessage, + classify_http_error, + validate_message_list, + validate_tools, +) + + +class MyProvider: + def __init__(self, *, base_url: str, model: str, api_key: str | None = None) -> None: + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + self._client = httpx.AsyncClient( + base_url=base_url, headers=headers, timeout=60.0 + ) + self.model = model + + async def ready(self) -> None: + try: + resp = await self._client.get("/v1/models") + except httpx.HTTPError as exc: + raise ProviderUnavailable(str(exc)) from exc + if resp.status_code != 200: + raise classify_http_error(resp) + + async def complete( + self, + messages: Sequence[Message], + tools: Sequence[Tool] | None = None, + config: RuntimeConfig | None = None, + ) -> Response: + validate_message_list(messages) + validate_tools(tools) + + body: dict[str, Any] = { + "model": self.model, + "messages": [_msg_to_wire(m) for m in messages], + } + if config and config.temperature is not None: + body["temperature"] = config.temperature + + try: + resp = await self._client.post("/v1/chat/completions", json=body) + except httpx.HTTPError as exc: + raise ProviderUnavailable(str(exc)) from exc + if resp.status_code != 200: + raise classify_http_error(resp) + + try: + payload = resp.json() + except ValueError as exc: + raise ProviderInvalidResponse("non-JSON response body") from exc + + choice = payload["choices"][0] + wire_msg = choice["message"] + usage = payload.get("usage", {}) + + return Response( + message=AssistantMessage(content=wire_msg.get("content") or ""), + finish_reason=choice["finish_reason"], + usage=Usage( + # All three fields are required; pass ``None`` when + # the provider doesn't report usage. + prompt_tokens=usage.get("prompt_tokens"), + completion_tokens=usage.get("completion_tokens"), + total_tokens=usage.get("total_tokens"), + ), + raw=payload, + ) + + +def _msg_to_wire(msg: Message) -> dict[str, Any]: + if isinstance(msg, SystemMessage): + return {"role": "system", "content": msg.content} + if isinstance(msg, UserMessage): + return {"role": "user", "content": msg.content} + if isinstance(msg, AssistantMessage): + return {"role": "assistant", "content": msg.content or ""} + if isinstance(msg, ToolMessage): + return { + "role": "tool", + "content": msg.content, + "tool_call_id": msg.tool_call_id, + } + raise ValueError(f"unhandled message type: {type(msg).__name__}") +``` + +## Contract checklist + +When you ship a Provider, the following must hold: + +**Statelessness + reentrancy.** + +- [ ] `complete()` MUST NOT carry state across calls. Each call sees + the full message list; there is no implicit conversation state. +- [ ] Multiple `complete()` calls MAY run concurrently on the same + Provider instance. The HTTP client should be safe for + concurrent use (httpx.AsyncClient is). + +**Non-mutation.** + +- [ ] `messages` passed to `complete()` MUST NOT be mutated. Build + wire bodies from copies / projections; never modify the input. + +**Boundary validation.** + +- [ ] Call `validate_message_list(messages)` to enforce the + list-level invariants (non-empty list; `system` is optional + but, when present, must be the first message; last must be + `user` or `tool`; every `tool_call_id` matches an earlier + assistant `ToolCall.id`). +- [ ] Call `validate_tools(tools)` if tools are accepted + (duplicate-name check). + +**Error mapping.** + +- [ ] Network failures (connection errors, timeouts) → + `ProviderUnavailable`. +- [ ] HTTP 401/403 → `ProviderAuthentication`. +- [ ] HTTP 400 → `ProviderInvalidRequest`. +- [ ] HTTP 404 with model-not-found → `ProviderInvalidModel`; + otherwise → `ProviderUnavailable`. +- [ ] HTTP 429 → `ProviderRateLimit` with `retry_after` from the + header. +- [ ] HTTP 503 with model-loading → `ProviderModelNotLoaded`; + otherwise → `ProviderUnavailable`. +- [ ] HTTP 5xx (other) → `ProviderUnavailable`. +- [ ] 200 OK that fails to parse into the expected response shape → + `ProviderInvalidResponse`. + +For OpenAI-compatible endpoints, `classify_http_error` does the +whole non-200 mapping table for you; the skeleton above just +delegates. + +**Finish reasons.** + +- [ ] Return one of: `"stop"`, `"length"`, `"tool_calls"`, + `"content_filter"`, `"error"`. Map the wire format's + finish-reason vocabulary to these five. + +## Beyond the skeleton + +The skeleton omits things real Providers usually need. Reach for +`openarmature.llm.OpenAIProvider` as a reference when you need any +of: + +- **Tool calls** — wire-mapping the `tool_calls` array on + `AssistantMessage` to the Provider's expected shape, parsing tool + results back from `ToolMessage`s. +- **Observability spans** — opt-in `started`/`completed` events + around the wire call so the OTel observer can build LLM spans. +- **Lenient response parsing** under `finish_reason="error"` — + degraded responses surface what they can; tool-call arguments that + fail to parse populate `arguments=None` instead of raising. +- **Catalog-aware `ready()`** — `GET /v1/models` plus checking + whether the bound model is in the returned catalog (and, for local + servers like LM Studio, whether it's actually loaded). +- **`Retry-After` parsing** — use `parse_retry_after` (re-exported + from `openarmature.llm`) to populate the `retry_after` field of + `ProviderRateLimit` from the response header. + +The conformance fixtures under +`tests/conformance/test_llm_provider.py` exercise the wire mapping +end-to-end; a custom Provider that passes those fixtures matches +the contract. diff --git a/docs/model-providers/index.md b/docs/model-providers/index.md new file mode 100644 index 00000000..c3259d57 --- /dev/null +++ b/docs/model-providers/index.md @@ -0,0 +1,116 @@ +# Model Providers + +A **Provider** is the seam between OpenArmature's graph engine and +any LLM backend — OpenAI's hosted API, an Anthropic Messages +endpoint, a local vLLM / LM Studio / llama.cpp server, or an +internal gateway. The engine doesn't know about LLMs; nodes call +providers, providers do the wire work. + +## What ships + +- **`OpenAIProvider`** — implements the OpenAI Chat Completions wire + format (`POST /v1/chat/completions`). Talks to OpenAI itself plus + the local servers that adopt the same format (vLLM, LM Studio, + llama.cpp). One Provider class covers most real-world deployments. + +For wire formats that aren't OpenAI Chat Completions (Anthropic +Messages, Bedrock, gateways with custom shapes), write your own. +See [Authoring a Provider](authoring.md). + +## The contract + +A Provider implements two async methods: + +```python +from collections.abc import Sequence +from typing import Protocol + +from openarmature.llm import Message, Response, RuntimeConfig, Tool + + +class Provider(Protocol): + async def ready(self) -> None: ... + async def complete( + self, + messages: Sequence[Message], + tools: Sequence[Tool] | None = None, + config: RuntimeConfig | None = None, + ) -> Response: ... +``` + +- **`ready()`** verifies the bound model is reachable. Pre-flight + check, typically called once before invoking the graph. +- **`complete()`** performs a single completion call and returns the + full `Response` — message, finish reason, token usage, raw wire + payload. + +### Behaviour guarantees + +- **Stateless.** Every `complete()` call carries the full message + list. There is no implicit conversation memory. +- **Reentrant.** Safe to call concurrently from many nodes. The + underlying HTTP client is shared but task-safe. +- **Non-mutating.** Inputs (`messages`, `tools`) are never modified. +- **No tool-call loops.** When the model wants to call a tool, the + Provider returns with `finish_reason="tool_calls"`. The caller + executes the tool and makes a follow-on `complete()` with the + result. The Provider does not re-enter itself. +- **No retry on transient errors.** That's middleware's job — wrap a + node in `RetryMiddleware` or similar. + +## Errors + +Seven canonical error categories cover every failure mode: + +| Error | Trigger | +| --------------------------- | --------------------------------------------- | +| `ProviderAuthentication` | 401 / 403 — bad key, expired token | +| `ProviderUnavailable` | 5xx, network failure, timeout | +| `ProviderInvalidModel` | Bound model doesn't exist on the provider | +| `ProviderModelNotLoaded` | Model known but not currently serving | +| `ProviderRateLimit` | 429 (with `Retry-After` exposed) | +| `ProviderInvalidResponse` | 200 OK that fails to parse | +| `ProviderInvalidRequest` | Malformed request (per-message or list-level) | + +Three of these (`Unavailable`, `RateLimit`, `ModelNotLoaded`) are +exported in `TRANSIENT_CATEGORIES` — the canonical "safe to retry" +set used by the default retry-middleware classifier. + +## A minimal example + +Direct usage of an `OpenAIProvider` against a local server, without +the engine in the picture: + +```python +import asyncio + +from openarmature.llm import OpenAIProvider, UserMessage + + +async def main() -> None: + provider = OpenAIProvider( + base_url="http://localhost:8000/v1", # any OpenAI-compatible endpoint + model="some-model", + api_key="optional-for-local-servers", + ) + # await provider.ready() # pre-flight; needs a live endpoint + # response = await provider.complete( + # messages=[UserMessage(content="Hello, world!")], + # ) + # print(response.message.content) + + +asyncio.run(main()) +``` + +In a real graph you'd construct one Provider at startup and let +nodes call it inside their bodies. + +## Where to next + +- **[Authoring a Provider](authoring.md)** — how to implement the + Protocol for a non-default wire format. Includes a ~60-line + skeleton + contract checklist. +- **[API reference: `openarmature.llm`](../reference/llm.md)** — the + full public surface (Message types, Response, Usage, RuntimeConfig, + error classes). diff --git a/docs/reference/checkpoint.md b/docs/reference/checkpoint.md new file mode 100644 index 00000000..4ce17340 --- /dev/null +++ b/docs/reference/checkpoint.md @@ -0,0 +1,7 @@ +# openarmature.checkpoint + +::: openarmature.checkpoint + options: + show_root_heading: false + show_source: false + heading_level: 2 diff --git a/docs/reference/graph.md b/docs/reference/graph.md new file mode 100644 index 00000000..43ff5f11 --- /dev/null +++ b/docs/reference/graph.md @@ -0,0 +1,7 @@ +# openarmature.graph + +::: openarmature.graph + options: + show_root_heading: false + show_source: false + heading_level: 2 diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 00000000..6910967d --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,15 @@ +# API Reference + +Auto-generated from docstrings. Pick a subpackage: + +- [`openarmature.graph`](graph.md) — state, builder, edges, nodes, + projections, fan-out, middleware, observer, reducers, errors. +- [`openarmature.llm`](llm.md) — Provider Protocol, message + tool + types, the OpenAIProvider, shared error helpers. +- [`openarmature.checkpoint`](checkpoint.md) — Checkpointer Protocol, + CheckpointRecord, in-memory + SQLite backends. +- [`openarmature.observability`](observability.md) — correlation + primitives and the optional OTel mapping (`[otel]` extra). + +Public surface is what each subpackage's `__init__.py` re-exports. Symbols +prefixed with `_` are package-private and not shown here. diff --git a/docs/reference/llm.md b/docs/reference/llm.md new file mode 100644 index 00000000..2f506812 --- /dev/null +++ b/docs/reference/llm.md @@ -0,0 +1,7 @@ +# openarmature.llm + +::: openarmature.llm + options: + show_root_heading: false + show_source: false + heading_level: 2 diff --git a/docs/reference/observability.md b/docs/reference/observability.md new file mode 100644 index 00000000..1cb445dc --- /dev/null +++ b/docs/reference/observability.md @@ -0,0 +1,7 @@ +# openarmature.observability + +::: openarmature.observability + options: + show_root_heading: false + show_source: false + heading_level: 2 diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..7daed5ec --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,326 @@ +/* ===== Footer meta row (mode-agnostic structural fix) ===== + * Compact, vertically-centered. Material's default leaves the copyright + * text and social icons at different baselines because each side has + * its own intrinsic padding. Force flex centering and reset side- + * specific padding so the block padding here controls the whole bar's + * height. */ +.md-footer-meta__inner { + padding-block: 5px; + align-items: center; +} + +.md-copyright, +.md-social { + padding: 0; + margin: 0; +} + +.md-social__link { + padding-block: 0; +} + +/* ===== Header shadow (mode-agnostic) ===== + * Remove Material's default drop shadow under the top nav so the bar + * sits flat against the 1px divider line below it. */ +.md-header, +.md-header[data-md-state="shadow"] { + box-shadow: none; +} + +/* ===== Landing CTAs (mode-agnostic) ===== + * Both buttons on the landing — "Get started" and "View on GitHub" — + * render identically, primary class or not. Material's default + * primary fills the bg with primary-fg color; override both classes + * so they behave as outline buttons with a hover-fill. + * + * Default: ``#9d4edd`` outline + matching purple text + transparent + * background (so the surface color shows through, dark or light). + * Hover / focus: solid ``#9d4edd`` fill + white text + light-gray + * outline as the hover indicator. */ +.md-typeset .md-button, +.md-typeset .md-button--primary { + background-color: transparent; + color: #9d4edd; + border-color: #9d4edd; +} + +/* Hover/focus split per theme so each theme's body text color is + * applied explicitly — the mode-agnostic ``var(--md-default-fg-color)`` + * variant resolved invisibly on the purple bg in practice. The + * scheme-attribute selector also raises specificity over Material's + * default ``.md-typeset .md-button:hover`` so the rule wins + * unambiguously regardless of cascade order. */ +[data-md-color-scheme="default"] .md-typeset .md-button:focus, +[data-md-color-scheme="default"] .md-typeset .md-button:hover, +[data-md-color-scheme="default"] .md-typeset .md-button--primary:focus, +[data-md-color-scheme="default"] .md-typeset .md-button--primary:hover { + background-color: #9d4edd; + color: hsla(0, 0%, 0%, 0.87); + border-color: #2e2f35; +} + +[data-md-color-scheme="slate"] .md-typeset .md-button:focus, +[data-md-color-scheme="slate"] .md-typeset .md-button:hover, +[data-md-color-scheme="slate"] .md-typeset .md-button--primary:focus, +[data-md-color-scheme="slate"] .md-typeset .md-button--primary:hover { + background-color: #9d4edd; + color: hsla(225, 15%, 90%, 0.82); + border-color: #2e2f35; +} + +/* The landing's six feature cards (``
``) are + * static — they don't link anywhere — so Material's default hover + * effect (border dissolves + elevation shadow) gives a misleading + * "I'm clickable" affordance. Pin the hover state to match the + * default to suppress the change entirely. */ +.md-typeset .grid.cards > ol > li:focus-within, +.md-typeset .grid.cards > ol > li:hover, +.md-typeset .grid.cards > ul > li:focus-within, +.md-typeset .grid.cards > ul > li:hover { + box-shadow: none; + border-color: var(--md-default-fg-color--lightest); +} + +/* ===== Dark-theme styling ===== + * All rules below are scoped to ``[data-md-color-scheme="slate"]`` so + * the light theme keeps Material's defaults until we tackle it + * separately. */ + +/* Unified surface color: all major sections share the footer-nav strip + * color (the bar just above the meta footer with prev/next arrows). + * + * Brand highlight: ``#9d4edd`` (vivid purple). Drives link color, + * hover treatment, and the selected/active nav indicator — the same + * variable Material's prev/next links use for their hover, so the + * rollover language is consistent everywhere. */ +[data-md-color-scheme="slate"] { + --md-default-bg-color: var(--md-footer-bg-color); + --md-accent-fg-color: #9d4edd; + --md-typeset-a-color: #9d4edd; + /* Inline code renders in a light lavender that pairs with the + * ``#9d4edd`` accent — the Material default was a pale blue that + * fought the brand colour. Fenced code blocks keep Material's + * default per-token syntax highlighting (keywords / strings / + * comments etc. via ``--md-code-hl-*``); they're not overridden + * here because the lavender shift looked too purple-heavy when + * applied across every token. */ + --md-code-fg-color: #e4c1f9; +} + +/* Inside fenced code blocks, the base ``--md-code-fg-color`` is what + * un-classified tokens fall back to (names / identifiers / etc.) — + * the lavender we want for inline code reads as too pink there. + * Re-scope the variable inside ``pre`` to the dark-theme body text + * tone so identifiers in code blocks read neutral while inline code + * stays lavender. */ +[data-md-color-scheme="slate"] .md-typeset pre { + --md-code-fg-color: rgba(226, 228, 233, 0.82); +} + +/* Backstop: explicitly colour the Pygments ``name`` / ``constant`` + * token classes inside slate-theme code blocks. These ordinarily + * read ``var(--md-code-hl-name-color)`` which defaults to + * ``var(--md-code-fg-color)`` — covered by the override above — but + * binding the colour directly here protects against any cascade + * order where the variable isn't picked up. */ +[data-md-color-scheme="slate"] .md-typeset pre .n, +[data-md-color-scheme="slate"] .md-typeset pre .kc { + color: rgba(226, 228, 233, 0.82); +} + +/* Active left-nav item: purple text to indicate the current page. */ +[data-md-color-scheme="slate"] .md-nav__link--active, +[data-md-color-scheme="slate"] .md-nav__item--active > .md-nav__link { + color: #9d4edd; +} + +/* Content-area link color — Material's slate scheme resolves + * ``--md-typeset-a-color`` from primary (black in our config), then + * patches it to a built-in blue via a slate-only override. Target the + * link selector directly to win against that override. */ +[data-md-color-scheme="slate"] .md-typeset a, +[data-md-color-scheme="slate"] .md-typeset a:hover, +[data-md-color-scheme="slate"] .md-typeset a:focus, +[data-md-color-scheme="slate"] .md-typeset a:active { + color: #9d4edd; +} + +/* Inline code inside a link (e.g., the ``openarmature.graph`` / + * ``openarmature.llm`` etc. links on the API Reference index): render + * in mid-saturation lavender in the default state. Hover/focus is + * left to Material's existing cascade. */ +[data-md-color-scheme="slate"] .md-typeset a code { + color: #cd8bf4; +} + +/* Hide the default Material book-icon logo next to the site name; the + * "OpenArmature" wordmark stands on its own and already links to the + * site root. */ +.md-header__button.md-logo { + display: none; +} + +/* Hide Material's auto-generated site-name header at the top of the + * primary sidebar — we have an explicit ``OpenArmature: index.md`` + * entry in the nav config which serves as the home link. */ +.md-nav--primary > .md-nav__title { + display: none; +} + +/* Disable the repo-source fade-in animation that fires on every page + * load (the GitHub link in the top right). */ +.md-source, +.md-source__icon, +.md-source__repository, +.md-source__facts, +.md-source__fact { + animation: none !important; +} + +[data-md-color-scheme="slate"] .md-header, +[data-md-color-scheme="slate"] .md-footer-meta { + background-color: var(--md-footer-bg-color); +} + +/* 1px subtle gray dividers between sections. Translucent white reads + * as gray on the dark surface and adapts if surface tone changes. */ +[data-md-color-scheme="slate"] .md-header { + border-bottom: 1px solid hsla(0, 0%, 100%, 0.1); +} + +[data-md-color-scheme="slate"] .md-sidebar--primary { + border-right: 1px solid hsla(0, 0%, 100%, 0.1); +} + +[data-md-color-scheme="slate"] .md-sidebar--secondary { + border-left: 1px solid hsla(0, 0%, 100%, 0.1); +} + +[data-md-color-scheme="slate"] .md-footer-nav { + border-top: 1px solid hsla(0, 0%, 100%, 0.1); +} + +[data-md-color-scheme="slate"] .md-footer-meta { + border-top: 1px solid hsla(0, 0%, 100%, 0.1); +} + +/* Search box: 1px gray outline + slightly rounded corners, matching + * the divider language. */ +[data-md-color-scheme="slate"] .md-search__form { + border: 1px solid hsla(0, 0%, 100%, 0.2); + border-radius: 0.4rem; +} + +/* ===== Light-theme styling ===== + * Mirrors the dark-theme structure. Unified surface color ``#f5f5f5`` + * (near-white with a hint of green); 1px translucent-black dividers + * (15% black reads as a soft gray line); same ``#9d4edd`` highlight + * for links + active nav items; same rounded outline on search. + * + * Material's ``primary: custom`` (set in mkdocs.yml for this scheme) + * delegates the brand color to CSS variables — we point both + * ``--md-primary-fg-color`` (header bg) and ``--md-default-bg-color`` + * (page bg) at ``#f5f5f5`` so the entire surface is one tone. + * ``--md-primary-bg-color`` is the text color *on* the primary + * surface; dark text reads well on the light sage. */ +[data-md-color-scheme="default"] { + --md-primary-fg-color: #f5f5f5; + --md-primary-bg-color: hsla(0, 0%, 0%, 0.87); + --md-primary-bg-color--light: hsla(0, 0%, 0%, 0.54); + --md-default-bg-color: #f5f5f5; + --md-footer-bg-color: #f5f5f5; + --md-accent-fg-color: #9d4edd; + + /* Material's footer-fg variables default to white-on-dark; flip to + * dark-on-light so any element that reads them (icons, secondary + * footer text) renders against our light surface. */ + --md-footer-fg-color: hsla(0, 0%, 0%, 0.87); + --md-footer-fg-color--light: hsla(0, 0%, 0%, 0.54); + --md-footer-fg-color--lighter: hsla(0, 0%, 0%, 0.32); + + /* Code blocks (and inline code) sit on a slightly darker tone than + * the surrounding page so they read as distinct from body text + * without a hard border. */ + --md-code-bg-color: #e9e9e9; +} + +[data-md-color-scheme="default"] .md-header, +[data-md-color-scheme="default"] .md-footer-meta { + background-color: #f5f5f5; +} + +[data-md-color-scheme="default"] .md-header { + border-bottom: 1px solid hsla(0, 0%, 0%, 0.15); +} + +[data-md-color-scheme="default"] .md-sidebar--primary { + border-right: 1px solid hsla(0, 0%, 0%, 0.15); +} + +[data-md-color-scheme="default"] .md-sidebar--secondary { + border-left: 1px solid hsla(0, 0%, 0%, 0.15); +} + +[data-md-color-scheme="default"] .md-footer-nav { + border-top: 1px solid hsla(0, 0%, 0%, 0.15); +} + +[data-md-color-scheme="default"] .md-footer-meta { + border-top: 1px solid hsla(0, 0%, 0%, 0.15); +} + +[data-md-color-scheme="default"] .md-nav__link--active, +[data-md-color-scheme="default"] .md-nav__item--active > .md-nav__link { + color: #9d4edd; +} + +[data-md-color-scheme="default"] .md-typeset a, +[data-md-color-scheme="default"] .md-typeset a:hover, +[data-md-color-scheme="default"] .md-typeset a:focus, +[data-md-color-scheme="default"] .md-typeset a:active { + color: #9d4edd; +} + +[data-md-color-scheme="default"] .md-search__form { + border: 1px solid hsla(0, 0%, 0%, 0.2); + border-radius: 0.4rem; +} + +/* Inline code inside a link (e.g., the ``openarmature.graph`` / + * ``openarmature.llm`` etc. links on the API Reference index): + * render in the same mid-saturation lavender as the dark theme so + * the visual treatment is consistent across schemes. */ +[data-md-color-scheme="default"] .md-typeset a code { + color: #cd8bf4; +} + +/* Prev/next footer-nav link text matches the content heading color + * (same as body text — headings only differ by weight). */ +[data-md-color-scheme="default"] .md-footer__link, +[data-md-color-scheme="default"] .md-footer__title, +[data-md-color-scheme="default"] .md-footer__direction { + color: var(--md-default-fg-color); +} + +/* Copyright text matches body text color. Material wraps the + * copyright in ``.md-copyright__highlight`` with its own color rule, + * so target both. */ +[data-md-color-scheme="default"] .md-copyright, +[data-md-color-scheme="default"] .md-copyright__highlight { + color: var(--md-default-fg-color); +} + +/* GH logo in the footer social row — inherit the dark body color so + * it's visible against the near-white bg. */ +[data-md-color-scheme="default"] .md-social__link { + color: var(--md-default-fg-color); +} + +/* GitHub source icon + label render in dark text on the light header + * (default white-on-primary would be invisible on the near-white bg). */ +[data-md-color-scheme="default"] .md-header__source, +[data-md-color-scheme="default"] .md-source, +[data-md-color-scheme="default"] .md-source__icon, +[data-md-color-scheme="default"] .md-source__repository { + color: hsla(0, 0%, 0%, 0.87); +} diff --git a/examples/01-linear-pipeline/main.py b/examples/01-linear-pipeline/main.py new file mode 100644 index 00000000..8eb2660b --- /dev/null +++ b/examples/01-linear-pipeline/main.py @@ -0,0 +1,201 @@ +"""Minimal openarmature demo: 2-node graph (plan → write) driven by a local vLLM. + +**Use case:** Take a topic (e.g. "the psychology of long walks") and produce +a short written piece — first plan a few angles, then write the article. + +**Demonstrates:** The minimal graph shape — typed `State`, the `append` +reducer, static edges, `END`, a two-node linear `plan → write` pipeline. + +Run with: + uv run python main.py "the psychology of long walks" +""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Mapping +from typing import Annotated, Any + +from openai import AsyncOpenAI +from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) +from pydantic import Field + +from openarmature.graph import END, CompiledGraph, GraphBuilder, State, append + +VLLM_BASE_URL = "http://localhost:8000/v1" +MODEL = "dark-side-of-the-code/Mistral-Small-24B-Instruct-2501-AWQ" + +client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed") + + +# ---------------------------------------------------------------------------- +# State schema +# ---------------------------------------------------------------------------- +# `State` is the immutable, strictly-typed object that flows through the graph. +# Every node receives an instance and returns a partial-update dict; the engine +# merges each update via per-field reducers and re-validates the result. +# +# Why this shape? The graph is a pipeline of pure state transitions. Nodes +# read a snapshot, emit a diff, and never mutate shared state — which makes +# "what was state when node X ran?" a question with a single clean answer. +# Under the hood, `State` is a Pydantic BaseModel with +# `model_config = ConfigDict(frozen=True, extra="forbid")` pre-baked into the +# base class — you don't touch `model_config` yourself. +# +# Non-obvious bits (see _docs/concepts.md for more): +# - Instances are FROZEN — nodes can't mutate `s.plan = ...`. They return +# {"plan": ...} and the engine applies the reducer. +# - Extra fields are FORBIDDEN — a node that returns {"typo": 1} raises +# StateValidationError instead of silently dropping the key. +# - Reducers attach per-field via Annotated[T, reducer]. Unannotated fields +# default to `last_write_wins` (new value replaces old). +# - Mutable defaults (list/dict) need Field(default_factory=...) — pydantic +# gotcha, not openarmature-specific. + + +class GraphState(State): + topic: str + plan: str = "" + output: str = "" + trace: Annotated[list[str], append] = Field(default_factory=list) + + +# ---------------------------------------------------------------------------- +# LLM client helper (not openarmature — just consumer plumbing) +# ---------------------------------------------------------------------------- +# Nothing below is graph-engine-specific; it's an OpenAI-compatible call to +# the local vLLM. Skim past this to the node functions if you're reading for +# openarmature concepts. + + +async def _chat(system: str, user: str) -> str: + messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system), + ChatCompletionUserMessageParam(role="user", content=user), + ] + resp = await client.chat.completions.create( + model=MODEL, + messages=messages, + temperature=0.4, + stream=False, + ) + return (resp.choices[0].message.content or "").strip() + + +# ---------------------------------------------------------------------------- +# Node functions +# ---------------------------------------------------------------------------- +# A node is `async def name(state) -> dict-of-partial-updates`. It reads the +# immutable state snapshot it was handed and returns *only* the fields it +# wants to change — the engine merges each field via its reducer. +# +# The idea: nodes are dumb and local. "I produced this plan." "I added this +# line to the trace." A node doesn't construct the next state, doesn't know +# what ran before it, and doesn't know what runs after. The graph is what +# composes them; each node stays a plain async function you could unit-test +# in isolation. +# +# For reducer-tracked fields (e.g. `trace: Annotated[list[str], append]`), +# return only the increment — `{"trace": ["plan"]}`, NOT +# `{"trace": s.trace + ["plan"]}`. The `append` reducer does the +# concatenation; returning the full list causes duplication. + + +async def plan_node(s: GraphState) -> Mapping[str, Any]: + content = await _chat( + system="You are a concise outliner. Respond with exactly 3 bullet points, no preamble.", + user=f"Outline the following topic in 3 bullets:\n\n{s.topic}", + ) + return {"plan": content, "trace": ["plan"]} + + +async def write_node(s: GraphState) -> Mapping[str, Any]: + content = await _chat( + system="You are an essayist. Write in prose, one tight paragraph, no bullet points, no headers.", + user=( + f"Topic: {s.topic}\n\nOutline:\n{s.plan}\n\n" + "Write a short paragraph (~120 words) that expands on the outline." + ), + ) + return {"output": content, "trace": ["write"]} + + +# ---------------------------------------------------------------------------- +# Graph construction +# ---------------------------------------------------------------------------- +# `GraphBuilder` is a mutable builder; `.compile()` turns it into an +# immutable `CompiledGraph` that's ready to run. The chain below does four +# things: +# 1. Register nodes under names — `.add_node("plan", plan_node)`. The name +# is what you reference in edges; the fn is the async callable. +# 2. Connect them with static edges — `.add_edge("plan", "write")` means +# "after `plan` runs and its update is merged, go to `write`". +# 3. Terminate with END — `.add_edge("write", END)` marks `write` as the +# last step, so the engine halts and `invoke()` returns. +# 4. Declare the entry point — `.set_entry("plan")` — where execution +# begins. +# +# Then `.compile()` runs the structural checks (no unreachable nodes, no +# dangling edges, no duplicate reducers on a field, etc.) and returns a +# `CompiledGraph`. Any problem with the graph's shape surfaces HERE, not at +# runtime — failing fast at the construction boundary. +# +# Each node has exactly ONE outgoing edge. Branching isn't done with multiple +# static edges; it's done with a single `.add_conditional_edge(source, fn)` where +# `fn(state) -> next_node_name`. We're linear here, so no conditional. +# +# `END` is a sentinel object, not a reserved string. Import it from +# `openarmature.graph`; don't use the literal `"END"` — it would be treated +# as a node name and fail `DanglingEdge` at compile time. + + +def build_graph() -> CompiledGraph[GraphState]: + return ( + GraphBuilder(GraphState) + .add_node("plan", plan_node) + .add_node("write", write_node) + .add_edge("plan", "write") + .add_edge("write", END) + .set_entry("plan") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Invoking the graph +# ---------------------------------------------------------------------------- +# `graph.invoke(initial_state)` runs the compiled graph from entry to END +# and returns the final state. It's `async` because nodes can (and usually +# do) perform IO. +# +# Initial state: construct an instance of your state class with the required +# fields filled in. Here that's just `GraphState(topic=topic)` — the other +# fields have schema-level defaults, so when `plan` (the entry node) is +# called it sees empty `plan`, empty `output`, and empty `trace`. +# +# `GraphBuilder` and `CompiledGraph` are generic on the state type — +# `build_graph()` is annotated as returning `CompiledGraph[GraphState]`, so +# `await graph.invoke(...)` returns a `GraphState`, not the base `State`. +# Typed field access (`final.topic`) works without a `cast()` on the return. +# (Earlier versions of the library required `cast(GraphState, ...)`; see +# `_docs/rough-edges.md` for the history.) + + +async def main() -> None: + topic = " ".join(sys.argv[1:]) or "the psychology of long walks" + graph = build_graph() + final = await graph.invoke(GraphState(topic=topic)) + + print(f"topic: {final.topic}\n") + print(f"plan:\n{final.plan}\n") + print(f"output:\n{final.output}\n") + print(f"trace: {final.trace}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/02-routing-and-subgraphs/main.py b/examples/02-routing-and-subgraphs/main.py new file mode 100644 index 00000000..557fdc00 --- /dev/null +++ b/examples/02-routing-and-subgraphs/main.py @@ -0,0 +1,459 @@ +"""openarmature demo: conditional routing + subgraph with a custom projection. + +**Use case:** A question-answering assistant. Classify the question, then +either give a one-shot quick answer or run a multi-step research +sub-pipeline (plan angles → gather notes → synthesize), then lightly +copy-edit the result. + +**Demonstrates:** Conditional edges (state-driven routing) via +`add_conditional_edge`, subgraph composition via `add_subgraph_node`, a +custom `ProjectionStrategy` for the parent ↔ subgraph boundary, and the +`merge` reducer for dict accumulation. + +This is the second demo in the series. It exercises three graph features +that `01-linear-pipeline/` didn't: + + 1. **Conditional edges.** The entry node classifies the question and the + graph routes to one of two branches based on that classification. + 2. **Subgraphs.** One of those branches is an entire sub-graph (plan → + gather → synthesize) wrapped as a single node in the parent graph. + 3. **A custom `ProjectionStrategy`.** The default projection (`FieldNameMatching`) + doesn't carry parent state *into* the subgraph — the subgraph starts + from its own schema's defaults. To pass the user's question in (and + shape what comes back out), we write a `ProjectionStrategy` by hand. + +And for good measure it also demonstrates the **merge reducer** for dict +accumulation, alongside the **append reducer** already seen in 01-linear-pipeline. + +Run with: + uv run python main.py "what year did the moon landing happen" # → quick branch + uv run python main.py "is espresso actually more caffeinated than drip?" # → research branch +""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Mapping +from typing import Annotated, Any + +from openai import AsyncOpenAI +from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) +from pydantic import Field + +from openarmature.graph import ( + END, + CompiledGraph, + GraphBuilder, + ProjectionStrategy, + State, + append, + merge, +) + +VLLM_BASE_URL = "http://localhost:8000/v1" +MODEL = "dark-side-of-the-code/Mistral-Small-24B-Instruct-2501-AWQ" + +client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed") + + +# ---------------------------------------------------------------------------- +# State schemas: one for the outer graph, one for the subgraph +# ---------------------------------------------------------------------------- +# The outer graph and the subgraph each have their OWN `State` subclass. This +# is one of openarmature's stronger opinions: a subgraph isn't a namespace +# inside the parent's schema — it's a separate pipeline with its own field +# shape. The boundary between them is an explicit translation step +# (a `ProjectionStrategy`), not implicit aliasing. +# +# Why separate schemas? Two reasons: +# - Subgraphs are reusable. The research sub-pipeline could be dropped into +# a different outer graph (or run on its own) without the parent's fields +# leaking into its type. +# - Boundaries are auditable. To find "what does the subgraph see?" you read +# one projection class, not a scattered naming convention. +# +# Both schemas below use the same reducer patterns we introduced in +# 01-linear-pipeline: `append` on a `trace` list, `merge` on a dict. Fields without +# an `Annotated[..., reducer]` get `last_write_wins` by default. + + +class AssistantState(State): + """Outer graph: takes a question, routes it, returns a formatted answer.""" + + question: str # required input + route: str = "" # set by `classify`; read by the conditional edge + answer: str = "" # set by whichever branch ran, then polished by `format_final` + trace: Annotated[list[str], append] = Field(default_factory=list) + tallies: Annotated[dict[str, int], merge] = Field(default_factory=dict) + + +class ResearchState(State): + """Subgraph: takes a question, produces a synthesized answer.""" + + question: str = "" # projected IN from the parent (see `QuestionProjection`) + angles: list[str] = Field(default_factory=list) # 3 angles picked by `plan_research` + notes: dict[str, str] = Field(default_factory=dict) # angle → note, produced by `gather` + answer: str = "" # final synthesis + trace: Annotated[list[str], append] = Field(default_factory=list) + + +# ---------------------------------------------------------------------------- +# LLM helper (not openarmature — plumbing) +# ---------------------------------------------------------------------------- + + +async def _chat(system: str, user: str) -> str: + messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system), + ChatCompletionUserMessageParam(role="user", content=user), + ] + resp = await client.chat.completions.create( + model=MODEL, + messages=messages, + temperature=0.3, + stream=False, + ) + return (resp.choices[0].message.content or "").strip() + + +# ---------------------------------------------------------------------------- +# Outer-graph nodes +# ---------------------------------------------------------------------------- +# Every node is the same shape as in 01-linear-pipeline: `async def(state) -> dict`, +# returning ONLY the fields it wants to change. The engine applies per-field +# reducers and re-validates. +# +# Three things worth noticing as you read these: +# +# (a) `classify` returns `route`. The conditional edge function further down +# reads that field off the post-merge state and dispatches. The fact +# that "routing decision" lives as a normal state field (not as some +# special return channel) is important: it's visible, it's typed, it's +# part of the trace, and you can inspect it in every downstream node. +# +# (b) Every node contributes to `tallies` via the `merge` reducer. Each +# return dict carries a small `{"tallies": {...}}` fragment; the +# reducer accumulates them into one dict on the final state. This is +# the same pattern used for metrics/counts across a pipeline — +# compose by emitting fragments, not by read-modify-write. +# +# (c) No node calls a subsequent node. `classify` doesn't know whether +# `quick_answer` or the research subgraph runs next. Nodes stay +# local; the graph decides the shape. + + +async def classify(s: AssistantState) -> Mapping[str, Any]: + """Pick a branch: 'quick' for anything answerable off-the-cuff, 'research' otherwise.""" + content = await _chat( + system=( + "You are a router. Read the question and answer with exactly one word: " + "'quick' if it can be answered in a sentence or two of general knowledge, " + "'research' if it benefits from considering multiple angles. No punctuation." + ), + user=s.question, + ) + route = "quick" if "quick" in content.lower() else "research" + return { + "route": route, + "trace": ["classify"], + "tallies": {"classify_calls": 1}, + } + + +async def quick_answer(s: AssistantState) -> Mapping[str, Any]: + """Fast path: one LLM call, direct answer.""" + content = await _chat( + system="Answer the question directly in one or two sentences. No preamble.", + user=s.question, + ) + return { + "answer": content, + "trace": ["quick_answer"], + "tallies": {"quick_answers": 1}, + } + + +async def format_final(s: AssistantState) -> Mapping[str, Any]: + """Final polish: take whatever the branch produced and tighten it.""" + content = await _chat( + system="Lightly copy-edit this answer for clarity. Preserve meaning. Return only the edited text.", + user=s.answer, + ) + return { + "answer": content, + "trace": ["format_final"], + "tallies": {"formatted": 1}, + } + + +# ---------------------------------------------------------------------------- +# Conditional edge function +# ---------------------------------------------------------------------------- +# The conditional edge fn is called with the state AFTER `classify`'s update +# has been merged — so `s.route` reflects what `classify` wrote. The return +# value MUST be a declared node name (as a string) OR the `END` sentinel. +# Anything else raises `RoutingError` at runtime. +# +# Small but important: the fn is synchronous. It's a routing decision, not a +# place to do IO. (If you need async routing logic, do it in the producing +# node and write the decision into a state field — which is exactly what +# `classify` does here.) +# +# Default case: we fall back to "quick_answer" if classify returned something +# unexpected. We could also return `END` to halt, or route to a dedicated +# error node. This is a design knob, not a library rule. + + +def route_from_classification(s: AssistantState) -> str: + if s.route == "research": + return "research" + return "quick_answer" + + +# ---------------------------------------------------------------------------- +# Subgraph: research pipeline (plan_research → gather → synthesize) +# ---------------------------------------------------------------------------- +# The subgraph is itself a full openarmature graph. It has its own state +# class (`ResearchState`), its own nodes, its own edges, its own entry. When +# compiled, it becomes a `CompiledGraph` — and a `CompiledGraph` can be +# wrapped as a node in an outer graph via `builder.add_subgraph_node(...)`. +# +# Why use a subgraph here at all? You could flatten these three nodes into +# the outer graph — plan_research, gather, synthesize as peers of classify +# and quick_answer. You'd lose two things: +# +# 1. **Encapsulation.** The outer graph cares about "research produces +# an answer." It doesn't need to know how. The subgraph hides the +# plan → gather → synthesize shape; swapping its implementation (e.g. +# add a "verify" step later) doesn't touch the outer wiring. +# +# 2. **Reusability.** This compiled subgraph is a plain Python value. The +# same `research_subgraph` could be used from a different outer graph, +# invoked directly for testing (`await research_subgraph.invoke(...)`), +# or composed inside yet another subgraph. +# +# The `ResearchState` is intentionally narrower than `AssistantState`. The +# subgraph shouldn't know or care about `route` or `tallies` — those are +# outer-graph concerns. This is what separate schemas buys you. + + +async def plan_research(s: ResearchState) -> Mapping[str, Any]: + """Pick 3 angles to explore.""" + content = await _chat( + system=( + "Given a question, propose 3 distinct angles worth investigating. " + "Respond with exactly 3 lines, one angle per line, no numbering or bullets." + ), + user=s.question, + ) + angles = [line.strip(" -*•\t") for line in content.splitlines() if line.strip()][:3] + return { + "angles": angles, + "trace": ["plan_research"], + } + + +async def gather(s: ResearchState) -> Mapping[str, Any]: + """Produce a short note for each angle. One LLM call, formatted result.""" + angles_joined = "\n".join(f"- {a}" for a in s.angles) + content = await _chat( + system=( + "For each angle below, write a 1-2 sentence note that speaks to it. " + "Format your response as:\n" + "ANGLE: \n" + "NOTE: \n\n" + "Repeat for each angle. No preamble." + ), + user=f"Question: {s.question}\n\nAngles:\n{angles_joined}", + ) + # Parse the ANGLE/NOTE blocks into a dict. Robust to extra whitespace; if + # the model goes off-script we fall back to a single catch-all note. + notes: dict[str, str] = {} + current_angle: str | None = None + for line in content.splitlines(): + line = line.strip() + if line.upper().startswith("ANGLE:"): + current_angle = line[len("ANGLE:") :].strip() + elif line.upper().startswith("NOTE:") and current_angle is not None: + notes[current_angle] = line[len("NOTE:") :].strip() + current_angle = None + if not notes: + notes = {"general": content[:400]} + return { + "notes": notes, + "trace": ["gather"], + } + + +async def synthesize(s: ResearchState) -> Mapping[str, Any]: + """Combine angle notes into a short paragraph answer.""" + notes_joined = "\n".join(f"- {a}: {n}" for a, n in s.notes.items()) + content = await _chat( + system=( + "Synthesize the notes below into one tight paragraph (~100 words) that " + "answers the question. No bullets, no headers." + ), + user=f"Question: {s.question}\n\nNotes:\n{notes_joined}", + ) + return { + "answer": content, + "trace": ["synthesize"], + } + + +def build_research_subgraph() -> CompiledGraph[ResearchState]: + return ( + GraphBuilder(ResearchState) + .add_node("plan_research", plan_research) + .add_node("gather", gather) + .add_node("synthesize", synthesize) + .add_edge("plan_research", "gather") + .add_edge("gather", "synthesize") + .add_edge("synthesize", END) + .set_entry("plan_research") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Custom projection: wiring parent ↔ subgraph +# ---------------------------------------------------------------------------- +# `FieldNameMatching` (the default) does one thing well and one thing not at +# all: +# +# - `project_out`: GOOD. It looks at the subgraph's final state, picks the +# fields whose names also exist on the parent, and returns them as a +# partial update — the parent's reducers then merge. That's how the +# subgraph's `trace` list flows back into the outer `trace` via the +# outer's `append` reducer. +# +# - `project_in`: DELIBERATELY LIMITED. It builds a fresh subgraph state +# from its schema's defaults — `subgraph_state_cls()`. The parent's +# state is ignored. This is the spec's default and it's an explicit +# design choice (v0.1.1 §2 Subgraph): subgraphs don't see the outer +# world unless the author opts in. +# +# For this demo we absolutely need the question in the subgraph. So we write +# a projection class that implements the `ProjectionStrategy` Protocol (see +# `openarmature.graph.projection`). Two methods: `project_in` decides what +# the subgraph starts with; `project_out` decides what leaks back. +# +# Teaching moment: this is the pattern for ALL non-trivial subgraph use. The +# default is fine for "inner computation shares the parent's field names" +# cases; anything else needs a custom projection. There's no runtime check +# that this is well-formed — a projection that returns a field the parent +# doesn't declare will surface at the boundary as a `StateValidationError` +# (extra="forbid" on the parent catches it). + + +# noinspection PyMethodMayBeStatic +class QuestionProjection: + """Pass `question` INTO the subgraph; pull `answer` and `trace` OUT. + + Signatures are typed directly against `AssistantState` and `ResearchState` + — `ProjectionStrategy[ParentT, ChildT]` is a generic Protocol, so + structural conformance is checked at the `_: ProjectionStrategy[...]` + annotation below without inheritance. + """ + + def project_in( + self, parent_state: AssistantState, subgraph_state_cls: type[ResearchState] + ) -> ResearchState: + # Construct the subgraph's initial state with the parent's question. + # All other subgraph fields use their schema defaults. + return subgraph_state_cls(question=parent_state.question) + + # noinspection PyUnusedLocal + def project_out( + self, + subgraph_final_state: ResearchState, + parent_state: AssistantState, + subgraph_state_cls: type[ResearchState], + ) -> Mapping[str, Any]: + # Bring `answer` back — merged via parent's `last_write_wins`. + # Bring `trace` back — merged via parent's `append` reducer, which + # concatenates the subgraph's trace entries after the parent's. + # Bump a tally so we can see the research branch ran. + return { + "answer": subgraph_final_state.answer, + "trace": subgraph_final_state.trace, + "tallies": {"research_runs": 1}, + } + + +# Static type check: a `QuestionProjection` instance satisfies the generic +# `ProjectionStrategy[AssistantState, ResearchState]` Protocol. This line is +# a no-op at runtime but catches shape drift at type-check time. +_: ProjectionStrategy[AssistantState, ResearchState] = QuestionProjection() + + +# ---------------------------------------------------------------------------- +# Outer graph construction +# ---------------------------------------------------------------------------- +# Four things to notice below: +# +# 1. `.add_subgraph_node("research", ..., projection=QuestionProjection())` — +# this is the only new method on `GraphBuilder` vs 01-linear-pipeline. It +# registers a compiled graph as a node, under the given name, with the +# given projection. +# +# 2. `.add_conditional_edge("classify", route_from_classification)` — the +# conditional edge. Exactly one outgoing edge per node still applies; +# a conditional IS that one edge. Compile will fail with +# `MultipleOutgoingEdges` if you mix a static and a conditional from +# the same source. +# +# 3. Both branches (`quick_answer` and `research`) merge back into +# `format_final`. You can fan out and fan in freely — the single- +# outgoing-edge rule is per node, not "no multiple predecessors". +# +# 4. `.compile()` at the end runs all the same structural checks as +# before — PLUS the reachability check understands conditional edges +# (conservatively: a conditional from X is treated as reaching every +# node, which keeps the unreachable check sound). + + +def build_graph() -> CompiledGraph[AssistantState]: + research_subgraph = build_research_subgraph() + + return ( + GraphBuilder(AssistantState) + .add_node("classify", classify) + .add_node("quick_answer", quick_answer) + .add_subgraph_node("research", research_subgraph, projection=QuestionProjection()) + .add_node("format_final", format_final) + .add_conditional_edge("classify", route_from_classification) + .add_edge("quick_answer", "format_final") + .add_edge("research", "format_final") + .add_edge("format_final", END) + .set_entry("classify") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------------- + + +async def main() -> None: + question = " ".join(sys.argv[1:]) or "is espresso actually more caffeinated than drip coffee?" + graph = build_graph() + final = await graph.invoke(AssistantState(question=question)) + + print(f"question: {final.question}") + print(f"route: {final.route}") + print() + print(f"answer:\n{final.answer}") + print() + print(f"trace: {final.trace}") + print(f"tallies: {final.tallies}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/03-explicit-subgraph-mapping/main.py b/examples/03-explicit-subgraph-mapping/main.py new file mode 100644 index 00000000..f31fa40e --- /dev/null +++ b/examples/03-explicit-subgraph-mapping/main.py @@ -0,0 +1,278 @@ +"""openarmature demo: same compiled subgraph reused at two sites in one parent +graph, each site with its own ExplicitMapping. + +**Use case:** Compare two topics ("rust vs go", "espresso vs drip coffee") +by running the same analysis subgraph on each, then synthesizing a verdict. + +**Demonstrates:** One compiled subgraph reused at two parent sites with +per-site `ExplicitMapping` — the case spec v0.2 / proposal 0002 was written +for, and the only way to express "run the same subgraph twice on disjoint +parent fields" without per-site projection classes that mirror each other. + +This is the case proposal 0002 was written for. Without explicit input/output +mapping, both sites would have to read from and write to the same parent +fields under name matching — making "run the same subgraph twice on different +inputs" structurally impossible. The two analyze_a/analyze_b sites here share +the SAME compiled subgraph value but project different parent fields in and +different parent fields out. + +Run with: + uv run python main.py "rust" "go" + uv run python main.py "espresso vs drip coffee" + uv run python main.py # → defaults to rust vs go +""" + +from __future__ import annotations + +import asyncio +import re +import sys +from collections.abc import Mapping +from typing import Annotated, Any + +from openai import AsyncOpenAI +from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) +from pydantic import Field + +from openarmature.graph import ( + END, + CompiledGraph, + ExplicitMapping, + GraphBuilder, + State, + append, +) + +VLLM_BASE_URL = "http://localhost:8000/v1" +MODEL = "dark-side-of-the-code/Mistral-Small-24B-Instruct-2501-AWQ" + +client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed") + + +# ---------------------------------------------------------------------------- +# State schemas: parent and subgraph +# ---------------------------------------------------------------------------- +# The parent compares two topics — call them A and B — and needs to capture a +# summary and a score for EACH topic. So the parent schema declares paired +# fields: a_summary/a_score and b_summary/b_score. +# +# The subgraph speaks in a single set of names — `topic`, `summary`, `score` — +# because it has no idea which side of the comparison it's running for. The +# mapping at each call site is what wires the subgraph's neutral names to the +# parent's per-side fields. +# +# This separation is the whole point. If the parent and subgraph shared field +# names (`summary`, `score`) and we relied on the spec's default field-name +# matching, the two subgraph calls would BOTH write to a single +# `parent.summary` field — the second call would clobber the first, and the +# comparator at the end would have no way to see both. With explicit mapping +# the two sites address disjoint parent fields and can't collide. + + +class ComparisonState(State): + """Outer graph: holds two topics, captures per-side analysis, emits a verdict.""" + + topic_a: str + topic_b: str + a_summary: str = "" + a_score: int = 0 + b_summary: str = "" + b_score: int = 0 + verdict: str = "" + trace: Annotated[list[str], append] = Field(default_factory=list) + + +class AnalysisState(State): + """Subgraph: takes a single topic, produces a one-line summary and a 1-10 score.""" + + topic: str = "" # projected IN from a parent field via inputs mapping + summary: str = "" # projected OUT to a parent field via outputs mapping + score: int = 0 # projected OUT to a parent field via outputs mapping + trace: Annotated[list[str], append] = Field(default_factory=list) + + +# ---------------------------------------------------------------------------- +# LLM helper (plumbing — not openarmature) +# ---------------------------------------------------------------------------- + + +async def _chat(system: str, user: str) -> str: + messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system), + ChatCompletionUserMessageParam(role="user", content=user), + ] + resp = await client.chat.completions.create( + model=MODEL, + messages=messages, + temperature=0.3, + stream=False, + ) + return (resp.choices[0].message.content or "").strip() + + +# ---------------------------------------------------------------------------- +# Subgraph nodes +# ---------------------------------------------------------------------------- +# Two nodes — `summarize` then `score` — running against the subgraph's own +# state. They're written entirely against `AnalysisState`; they don't know +# (and can't know) that the parent calls them twice with different mappings. +# That's the encapsulation a subgraph buys. + + +async def summarize(s: AnalysisState) -> Mapping[str, Any]: + content = await _chat( + system="In one tight sentence, summarize what the user-supplied topic IS. No preamble.", + user=s.topic, + ) + return {"summary": content, "trace": ["summarize"]} + + +async def score(s: AnalysisState) -> Mapping[str, Any]: + content = await _chat( + system=( + "Given a topic and a one-line summary, rate the topic from 1 to 10 on overall " + "interestingness/usefulness. Reply with just the integer, no other text." + ), + user=f"Topic: {s.topic}\nSummary: {s.summary}", + ) + match = re.search(r"\d+", content) + n = int(match.group()) if match else 5 + return {"score": max(1, min(10, n)), "trace": ["score"]} + + +def build_analysis_subgraph() -> CompiledGraph[AnalysisState]: + return ( + GraphBuilder(AnalysisState) + .add_node("summarize", summarize) + .add_node("score", score) + .add_edge("summarize", "score") + .add_edge("score", END) + .set_entry("summarize") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Outer-graph node: synthesize a verdict from both per-side analyses +# ---------------------------------------------------------------------------- + + +async def synthesize(s: ComparisonState) -> Mapping[str, Any]: + content = await _chat( + system=( + "Two topics have been analyzed. Pick a winner (or call it a tie) in one short " + "paragraph. Be specific about WHY, citing the summaries." + ), + user=( + f"Topic A: {s.topic_a}\n" + f" summary: {s.a_summary}\n" + f" score: {s.a_score}/10\n\n" + f"Topic B: {s.topic_b}\n" + f" summary: {s.b_summary}\n" + f" score: {s.b_score}/10\n" + ), + ) + return {"verdict": content, "trace": ["synthesize"]} + + +# ---------------------------------------------------------------------------- +# Outer graph: ONE compiled subgraph at TWO sites with DIFFERENT mappings +# ---------------------------------------------------------------------------- +# The point of this entire example lives in the next ~15 lines: the same +# compiled `analysis` subgraph is registered twice as a node, and each +# registration carries its own `ExplicitMapping`. +# +# `analyze_a` says "feed me parent.topic_a as my `topic`; write my `summary` +# back as parent.a_summary and my `score` as parent.a_score." +# +# `analyze_b` says the same thing but with the B-side parent fields. +# +# The two sites address disjoint parent fields. They CANNOT collide. +# +# Why is this only doable with explicit mapping? +# +# - The default field-name matching can't help: it operates on names alone, +# with no way to express "this site reads A, that site reads B." Both +# sites would write `parent.summary` and clobber each other. +# +# - A custom `ProjectionStrategy` (the 02-routing-and-subgraphs approach) +# would have to differ per call site — you'd write two distinct projection +# classes that do the same thing in mirror image. That's exactly the +# boilerplate proposal 0002 removes. +# +# - The subgraph can't rename its own fields to avoid the clash: its schema +# is fixed at compile time and shared across both call sites. Wrong layer. +# +# Note also that `trace` is included in both `outputs` mappings — so each +# subgraph run's per-node trace is appended to the parent's `trace` field via +# the parent's `append` reducer. The final `trace` will show the subgraph +# nodes running TWICE (once per analyze site), interleaved with the outer +# nodes. + + +def build_graph() -> CompiledGraph[ComparisonState]: + analysis = build_analysis_subgraph() + + return ( + GraphBuilder(ComparisonState) + .add_subgraph_node( + "analyze_a", + analysis, + projection=ExplicitMapping[ComparisonState, AnalysisState]( + inputs={"topic": "topic_a"}, + outputs={"a_summary": "summary", "a_score": "score", "trace": "trace"}, + ), + ) + .add_subgraph_node( + "analyze_b", + analysis, + projection=ExplicitMapping[ComparisonState, AnalysisState]( + inputs={"topic": "topic_b"}, + outputs={"b_summary": "summary", "b_score": "score", "trace": "trace"}, + ), + ) + .add_node("synthesize", synthesize) + .add_edge("analyze_a", "analyze_b") + .add_edge("analyze_b", "synthesize") + .add_edge("synthesize", END) + .set_entry("analyze_a") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------------- + + +async def main() -> None: + args = sys.argv[1:] + if len(args) >= 2: + topic_a, topic_b = args[0], args[1] + elif len(args) == 1 and " vs " in args[0].lower(): + topic_a, topic_b = re.split(r" vs ", args[0], maxsplit=1, flags=re.IGNORECASE) + else: + topic_a, topic_b = "rust", "go" + + graph = build_graph() + final = await graph.invoke(ComparisonState(topic_a=topic_a, topic_b=topic_b)) + + print(f"topic A: {final.topic_a}") + print(f" summary: {final.a_summary}") + print(f" score: {final.a_score}/10") + print() + print(f"topic B: {final.topic_b}") + print(f" summary: {final.b_summary}") + print(f" score: {final.b_score}/10") + print() + print(f"verdict:\n{final.verdict}") + print() + print(f"trace: {final.trace}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/04-observer-hooks/main.py b/examples/04-observer-hooks/main.py new file mode 100644 index 00000000..5c079ab6 --- /dev/null +++ b/examples/04-observer-hooks/main.py @@ -0,0 +1,309 @@ +"""openarmature demo: observer hooks for structured logging + per-call metrics. + +**Use case:** Add observability to a small three-stage answer pipeline (an +outer `draft → review → finalize` flow where `review` is its own subgraph) +without changing any node code. A graph-attached console tracer prints +every node-boundary event to stderr; an invocation-scoped metrics +collector tallies counts for THIS specific call. + +**Demonstrates:** Observer hooks (spec v0.3 / proposal 0003) — registering +graph-attached and invocation-scoped observers, the `NodeEvent` shape, +namespace chaining across a subgraph boundary, the `drain()` call required +for short-lived processes, and how observers see structured pre/post state +without nodes having to log anything themselves. + +Run with: + uv run python main.py "what year did the moon landing happen" + uv run python main.py "explain the rise of espresso culture" + uv run python main.py # → uses default question +""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Mapping +from typing import Annotated, Any + +from openai import AsyncOpenAI +from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) +from pydantic import Field + +from openarmature.graph import ( + END, + CompiledGraph, + ExplicitMapping, + GraphBuilder, + NodeEvent, + Observer, + State, + append, +) + +VLLM_BASE_URL = "http://localhost:8000/v1" +MODEL = "dark-side-of-the-code/Mistral-Small-24B-Instruct-2501-AWQ" + +client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed") + + +# ---------------------------------------------------------------------------- +# State schemas +# ---------------------------------------------------------------------------- +# The outer `AnswerState` and the subgraph's `ReviewState` overlap on the +# fields we want to flow across the boundary (`draft`, `revised`, `trace`) +# and each adds its own (`question` outside, `critique` inside). Keeping +# the fields aligned by name lets the subgraph's outputs flow back through +# the spec's default field-name matching — except for `draft`, which we +# DO need to project IN. Hence the `inputs={"draft": "draft"}` mapping +# below; absent `outputs` falls back to field-name matching for the way +# back, projecting `revised` and `trace`. + + +class AnswerState(State): + """Outer graph: takes a question through draft → review → finalize.""" + + question: str + draft: str = "" + revised: str = "" + trace: Annotated[list[str], append] = Field(default_factory=list) + + +class ReviewState(State): + """Subgraph: critique a draft, then revise it.""" + + draft: str = "" + critique: str = "" + revised: str = "" + trace: Annotated[list[str], append] = Field(default_factory=list) + + +# ---------------------------------------------------------------------------- +# LLM helper (plumbing — not openarmature) +# ---------------------------------------------------------------------------- + + +async def _chat(system: str, user: str) -> str: + messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system), + ChatCompletionUserMessageParam(role="user", content=user), + ] + resp = await client.chat.completions.create( + model=MODEL, + messages=messages, + temperature=0.3, + stream=False, + ) + return (resp.choices[0].message.content or "").strip() + + +# ---------------------------------------------------------------------------- +# Outer-graph nodes +# ---------------------------------------------------------------------------- + + +async def draft_node(s: AnswerState) -> Mapping[str, Any]: + content = await _chat( + system="Answer the question in two or three sentences. No preamble.", + user=s.question, + ) + return {"draft": content, "trace": ["draft"]} + + +async def finalize(_s: AnswerState) -> Mapping[str, Any]: + """Outer endpoint marker. The subgraph's revision is already in + `revised` via projection; this node just records that the run is done. + """ + return {"trace": ["finalize"]} + + +# ---------------------------------------------------------------------------- +# Subgraph nodes (review) +# ---------------------------------------------------------------------------- + + +async def critique(s: ReviewState) -> Mapping[str, Any]: + content = await _chat( + system=( + "Read the draft answer below and write one short paragraph " + "criticizing it — what's missing, what's vague, what could be " + "tightened. No preamble." + ), + user=s.draft, + ) + return {"critique": content, "trace": ["critique"]} + + +async def revise(s: ReviewState) -> Mapping[str, Any]: + content = await _chat( + system=( + "Rewrite the draft to address the critique. Keep it concise, two or three sentences. No preamble." + ), + user=f"Draft:\n{s.draft}\n\nCritique:\n{s.critique}", + ) + return {"revised": content, "trace": ["revise"]} + + +def build_review_subgraph() -> CompiledGraph[ReviewState]: + return ( + GraphBuilder(ReviewState) + .add_node("critique", critique) + .add_node("revise", revise) + .add_edge("critique", "revise") + .add_edge("revise", END) + .set_entry("critique") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Observer 1: console tracer (graph-attached) +# ---------------------------------------------------------------------------- +# A bare async function. Conforms to the `Observer` Protocol structurally — +# any `async def(event: NodeEvent) -> None` works. Graph-attached observers +# fire on every invocation of the compiled graph until removed. + + +async def console_tracer(event: NodeEvent) -> None: + """Print one structured line per node boundary to stderr. + + Format: `[step=N] namespace.path → fields_changed_in_this_step` + On error, format flips to `... ✗ error_category`. + """ + namespace = ".".join(event.namespace) + if event.error is not None: + print( + f"[step={event.step}] {namespace} ✗ {event.error.category}", + file=sys.stderr, + ) + return + + pre = event.pre_state.model_dump() + post = event.post_state.model_dump() if event.post_state is not None else {} + # Keys whose values changed during this node — gives the reader a + # field-level view of what each node DID without nodes having to log. + changed = {k: post[k] for k in post if pre.get(k) != post[k]} + print(f"[step={event.step}] {namespace} → {changed}", file=sys.stderr) + + +# Static type check: a bare `async def` matches the `Observer` Protocol +# structurally. Catches signature drift at type-check time. +_: Observer = console_tracer + + +# ---------------------------------------------------------------------------- +# Observer 2: per-invocation metrics collector +# ---------------------------------------------------------------------------- +# A class with an `async __call__` method. Same Protocol; class-shaped +# observers are useful when you want per-invocation state that isn't a +# global. We pass the instance to `invoke(observers=[...])` so it fires +# only for THAT call — graph-attached observers are persistent across +# calls; invocation-scoped observers are per-call. + + +class InvocationMetrics: + """Counts events and errors for one invocation; collects unique + namespaces visited (a quick way to see whether a subgraph ran).""" + + def __init__(self) -> None: + self.events: int = 0 + self.errors: int = 0 + self.namespaces: set[tuple[str, ...]] = set() + + async def __call__(self, event: NodeEvent) -> None: + self.events += 1 + if event.error is not None: + self.errors += 1 + self.namespaces.add(event.namespace) + + +# ---------------------------------------------------------------------------- +# Outer graph construction +# ---------------------------------------------------------------------------- + + +def build_graph() -> CompiledGraph[AnswerState]: + review = build_review_subgraph() + return ( + GraphBuilder(AnswerState) + .add_node("draft", draft_node) + .add_subgraph_node( + "review", + review, + projection=ExplicitMapping[AnswerState, ReviewState]( + # Pass the outer draft IN. Without this, the subgraph would + # critique an empty string from its own schema default. + # Leaving `outputs` absent falls back to default field-name + # matching, which projects subgraph.revised → parent.revised + # and subgraph.trace → parent.trace via the parent's append + # reducer. (Subgraph.critique is discarded — no parent field + # of that name.) + inputs={"draft": "draft"}, + ), + ) + .add_node("finalize", finalize) + .add_edge("draft", "review") + .add_edge("review", "finalize") + .add_edge("finalize", END) + .set_entry("draft") + .compile() + ) + + +# ---------------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------------- +# The shape of an observer-aware run: +# +# 1. Build the graph. +# 2. Attach graph-level observers (console_tracer here) — these fire on +# every invoke of this compiled graph. +# 3. For each invoke, optionally pass invocation-scoped observers +# (metrics here) — they fire only for THAT invocation. +# 4. await drain() before exiting. The graph dispatches events to a +# background queue; without drain, a short-lived process can exit +# before the queue's worker has delivered them. In a long-running +# service this isn't necessary because the event loop keeps running. + + +async def main() -> None: + question = " ".join(sys.argv[1:]) or "what year did the moon landing happen" + + graph = build_graph() + graph.attach_observer(console_tracer) + + metrics = InvocationMetrics() + try: + final = await graph.invoke( + AnswerState(question=question), + observers=[metrics], + ) + finally: + # Required for short-lived processes: invoke() returns when the + # graph reaches END regardless of whether the observer queue has + # finished. The try/finally also matters on the failure path — + # per spec v0.3 §6, the engine dispatches a failure event with + # `error` populated BEFORE propagating, and that event is exactly + # what a debugging user would want to see. Without `finally`, an + # invoke that raises would lose those late events. + await graph.drain() + + print() + print(f"question: {final.question}") + print() + print(f"draft:\n{final.draft}") + print() + print(f"revised:\n{final.revised}") + print() + print("per-invocation metrics:") + print(f" events seen: {metrics.events}") + print(f" errors observed: {metrics.errors}") + print(f" unique namespaces: {len(metrics.namespaces)}") + print(f" trace order: {final.trace}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/05-nested-subgraphs/main.py b/examples/05-nested-subgraphs/main.py new file mode 100644 index 00000000..3fd8160a --- /dev/null +++ b/examples/05-nested-subgraphs/main.py @@ -0,0 +1,236 @@ +"""openarmature demo: two-level subgraph nesting and the depth invariants. + +**Use case:** Show what observer events look like when subgraphs are nested +TWO levels deep. The first four examples cover composition basics; this one +zooms in on what happens at depth > 1, where the engine's namespace chain, +parent-state stack, and step counter all extend across multiple subgraph +boundaries. + +**Demonstrates:** Spec graph-engine §6 depth invariants — + +- `len(parent_states) == len(namespace) - 1` at every depth, including 3. +- `step` is monotonic across both subgraph boundaries (no resets). +- `parent_states[k]` is the k-th containing graph's state at the moment + that graph entered the subgraph leading to this event. Snapshots are + stable for the duration of the inner run. +- Subgraph state shape is the SUBGRAPH's schema (not the parent's), + so `pre_state`/`post_state` carry the inner graph's fields. + +The bodies are trivial integer updates so the observer output is short +and predictable. Read the printed output top-to-bottom — depth is shown +both numerically and via indentation, and the parent-states list grows +as the engine descends and shrinks as it returns. + +Run with: + uv run python main.py +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Annotated, Any + +from pydantic import Field + +from openarmature.graph import ( + END, + CompiledGraph, + GraphBuilder, + NodeEvent, + State, + append, +) + +# --------------------------------------------------------------------------- +# State schemas — same shape at every level so default field-name matching +# projects values across boundaries. +# --------------------------------------------------------------------------- + + +class InnerState(State): + v: int = 0 + trace: Annotated[list[str], append] = Field(default_factory=list) + + +class MiddleState(State): + v: int = 0 + trace: Annotated[list[str], append] = Field(default_factory=list) + + +class OuterState(State): + v: int = 0 + trace: Annotated[list[str], append] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Node bodies — trivial updates. The numbers are chosen so it's easy to see +# at a glance which level a value came from in the final state: +# 1, 999 → outer +# 10, 20 → middle +# 100, 200 → inner +# --------------------------------------------------------------------------- + + +async def outer_a(_: OuterState) -> Mapping[str, Any]: + return {"v": 1, "trace": ["outer_a"]} + + +async def outer_b(_: OuterState) -> Mapping[str, Any]: + return {"v": 999, "trace": ["outer_b"]} + + +async def mid_x(_: MiddleState) -> Mapping[str, Any]: + return {"v": 10, "trace": ["mid_x"]} + + +async def mid_y(_: MiddleState) -> Mapping[str, Any]: + return {"v": 20, "trace": ["mid_y"]} + + +async def inner_p(_: InnerState) -> Mapping[str, Any]: + return {"v": 100, "trace": ["inner_p"]} + + +async def inner_q(_: InnerState) -> Mapping[str, Any]: + return {"v": 200, "trace": ["inner_q"]} + + +# --------------------------------------------------------------------------- +# Graph builders — innermost first, since each outer graph references the +# inner one as a SubgraphNode and needs it compiled at build time. +# --------------------------------------------------------------------------- + + +def build_inner() -> CompiledGraph[InnerState]: + builder: GraphBuilder[InnerState] = GraphBuilder(InnerState) + builder.set_entry("inner_p") + builder.add_node("inner_p", inner_p) + builder.add_node("inner_q", inner_q) + builder.add_edge("inner_p", "inner_q") + builder.add_edge("inner_q", END) + return builder.compile() + + +def build_middle(inner: CompiledGraph[InnerState]) -> CompiledGraph[MiddleState]: + builder: GraphBuilder[MiddleState] = GraphBuilder(MiddleState) + builder.set_entry("mid_x") + builder.add_node("mid_x", mid_x) + builder.add_subgraph_node("mid_inner", inner) + builder.add_node("mid_y", mid_y) + builder.add_edge("mid_x", "mid_inner") + builder.add_edge("mid_inner", "mid_y") + builder.add_edge("mid_y", END) + return builder.compile() + + +def build_outer(middle: CompiledGraph[MiddleState]) -> CompiledGraph[OuterState]: + builder: GraphBuilder[OuterState] = GraphBuilder(OuterState) + builder.set_entry("outer_a") + builder.add_node("outer_a", outer_a) + builder.add_subgraph_node("outer_mid", middle) + builder.add_node("outer_b", outer_b) + builder.add_edge("outer_a", "outer_mid") + builder.add_edge("outer_mid", "outer_b") + builder.add_edge("outer_b", END) + return builder.compile() + + +def build_graph() -> CompiledGraph[OuterState]: + """Top-level graph factory — the convention every example exposes for + CI smoke validation. Builds inner first, then middle (which references + inner), then outer (which references middle). + """ + return build_outer(build_middle(build_inner())) + + +# --------------------------------------------------------------------------- +# Observer — formats events so depth invariants are visually obvious. +# --------------------------------------------------------------------------- + + +def _fmt(state: Any) -> str: + """Compact one-line state dump.""" + if state is None: + return "—" + return f"v={state.v} trace={state.trace}" + + +async def depth_observer(event: NodeEvent) -> None: + """Print every event with depth-aware indentation. + + The leading spaces visualize how deep into the nested subgraphs this + event came from. Number of `parent_states` entries always equals + `len(namespace) - 1` per the §6 invariant. + """ + depth = len(event.namespace) + indent = " " * (depth - 1) + ns = " > ".join(event.namespace) + parents_summary = " | ".join(_fmt(p) for p in event.parent_states) if event.parent_states else "(none)" + + if event.phase == "started": + line = ( + f"{indent}[step {event.step}] depth={depth} ns=[{ns}]\n" + f"{indent} started pre={_fmt(event.pre_state)}\n" + f"{indent} parents: {parents_summary}" + ) + else: # completed + if event.error is not None: + line = ( + f"{indent} completed pre={_fmt(event.pre_state)} " + f"ERROR={type(event.error).__name__}: {event.error}" + ) + else: + line = f"{indent} completed pre={_fmt(event.pre_state)} post={_fmt(event.post_state)}" + print(line) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +async def main() -> None: + outer = build_graph() + + # Observer attached to the OUTER graph fires for every node executed + # during this invocation, including subgraph-internal nodes at depth + # 2 and 3. (See example 04 for graph-attached vs invocation-scoped + # subtleties; this example uses one observer to keep the output clean.) + outer.attach_observer(depth_observer) + + print("=" * 72) + print("Two-level subgraph nesting — observer events at depths 1, 2, 3") + print("=" * 72) + print() + print("Read top-to-bottom. Indentation = depth. Watch the parents list") + print("grow as the engine descends and shrink as it returns.") + print() + + try: + final = await outer.invoke(OuterState()) + finally: + # Drain MUST be awaited in short-lived processes per spec §6 — without + # it, this script could exit before the delivery worker finishes + # processing the queue, losing late events. In a `finally` so failure + # events flush even if invoke() raises. + await outer.drain() + + print() + print("=" * 72) + print("Final outer state (after both subgraphs project back via") + print("default field-name matching):") + print(f" v = {final.v}") + print(f" trace = {final.trace}") + print("=" * 72) + print() + print("Notice in the events above:") + print(" - step counter never resets across subgraph boundaries (0..5)") + print(" - namespace length matches depth at every event") + print(" - parent_states length = depth - 1, always") + print(" - inner_p and inner_q share the same parents list because") + print(" neither outer nor middle is stepping while inner runs") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..c6c47ca1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,51 @@ +# Examples + +End-to-end demo projects for `openarmature`. Each is a standalone +`main.py` you can run against a local OpenAI-compatible LLM endpoint +(vLLM, LM Studio, llama.cpp server, etc.). + +## Demos + +### [`01-linear-pipeline/`](./01-linear-pipeline/main.py) + +Minimal two-node graph (`plan → write`). Demonstrates: typed `State`, +the `append` reducer, static edges, `END`. + +### [`02-routing-and-subgraphs/`](./02-routing-and-subgraphs/main.py) + +Question-answering assistant — classify, then short-answer or +research-subgraph, then copy-edit. Demonstrates: conditional edges, +`SubgraphNode`, custom `ProjectionStrategy`, the `merge` reducer. + +### [`03-explicit-subgraph-mapping/`](./03-explicit-subgraph-mapping/main.py) + +Compare two topics by running the same analysis subgraph on each. +Demonstrates: `ExplicitMapping` for reusing one compiled subgraph at +multiple parent sites with disjoint parent fields. + +### [`04-observer-hooks/`](./04-observer-hooks/main.py) + +Add observability to a `draft → review → finalize` pipeline without +changing any node code. Demonstrates: `attach_observer`, `NodeEvent`, +namespace chaining across subgraph boundaries, both function-shaped +and class-shaped observers. + +### [`05-nested-subgraphs/`](./05-nested-subgraphs/main.py) + +Two levels of nested subgraphs (`outer → middle → inner`) with a +depth-aware observer printing the descent and return. Demonstrates +spec graph-engine §6 depth invariants. + +## Running + +```bash +# From the repo root, install the examples dep group: +uv sync --group examples + +# Run any demo: +uv run python examples/01-linear-pipeline/main.py "the psychology of long walks" +``` + +All five demos expect an OpenAI-compatible LLM endpoint at +`http://localhost:8000/v1`. Edit `VLLM_BASE_URL` and `MODEL` at the +top of each `main.py` to point elsewhere. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..d4a9965a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,126 @@ +site_name: OpenArmature +site_url: https://docs.openarmature.ai/ +site_description: Workflow framework for LLM pipelines and tool-calling agents +copyright: "©OpenArmature 2026 to present" +repo_url: https://github.com/LunarCommand/openarmature-python +repo_name: LunarCommand/openarmature-python +edit_uri: edit/main/docs/ + +# Local ``mkdocs serve`` binds here. Default 8000 was already in use +# on the maintainer's workstation; 8765 is the project convention. +dev_addr: "127.0.0.1:8765" + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: teal + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: teal + toggle: + icon: material/weather-night + name: Switch to light mode + features: + - content.code.copy + - content.code.annotate + - content.tabs.link + - navigation.indexes + - navigation.sections + - navigation.tracking + - navigation.footer + - search.highlight + - search.suggest + - toc.follow + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + members_order: source + separate_signature: true + filters: ["!^_"] + merge_init_into_class: true + show_signature_annotations: true + signature_crossrefs: true + - glightbox + # /llms.txt + /llms-full.txt for AI coding assistants. Plain-text + # versions of the docs that assistants can ingest in one shot — + # matches the charter's "agent-friendly" framing. + - llmstxt: + full_output: llms-full.txt + sections: + Getting Started: + - getting-started/*.md + Concepts: + - concepts/*.md + Reference: + - reference/*.md + +markdown_extensions: + - tables + - toc: + permalink: true + - admonition + - attr_list + - md_in_html + - footnotes + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + +nav: + - OpenArmature: index.md + - Getting Started: + - Quickstart: getting-started/index.md + - Concepts: + - concepts/index.md + - State and reducers: concepts/state-and-reducers.md + - Graphs: concepts/graphs.md + - Composition: concepts/composition.md + - Fan-out: concepts/fan-out.md + - Observability: concepts/observability.md + - Checkpointing: concepts/checkpointing.md + - Model Providers: + - model-providers/index.md + - Authoring a Provider: model-providers/authoring.md + - Reference: + - reference/index.md + - openarmature.graph: reference/graph.md + - openarmature.llm: reference/llm.md + - openarmature.checkpoint: reference/checkpoint.md + - openarmature.observability: reference/observability.md + - Contributing: contributing/index.md + +extra: + # Hide the "Made with Material for MkDocs" footer. + generator: false + # Versioned docs via mike. The dropdown shows once at least one + # version has been published (``mike deploy ``); pre-1.0 + # it stays hidden because nothing's deployed. + version: + provider: mike + social: + - icon: fontawesome/brands/github + link: https://github.com/LunarCommand/openarmature-python + +extra_css: + - stylesheets/extra.css diff --git a/pyproject.toml b/pyproject.toml index c465e022..f9e4c9b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,26 @@ dev = [ "pyright>=1.1.360", "pytest>=8.2", "pytest-asyncio>=0.23", + "pytest-examples>=0.0.13", "pyyaml>=6.0", "ruff>=0.5", "types-pyyaml", ] +docs = [ + "mkdocs>=1.6,<2", + "mkdocs-material[imaging]>=9.5,<10", + "mkdocstrings[python]>=0.27,<1", + "pymdown-extensions>=10.0,<11", + "mkdocs-glightbox>=0.4,<1", + "mkdocs-llmstxt>=0.1,<1", + "mike>=2.1,<3", +] +# Deps for the runnable demos under ``examples/``. The demos call out to +# an OpenAI-compatible LLM endpoint, so ``openai`` is the one extra +# they need beyond the core package. +examples = [ + "openai>=1.40", +] [tool.hatch.build.targets.wheel] packages = ["src/openarmature"] diff --git a/src/openarmature/checkpoint/__init__.py b/src/openarmature/checkpoint/__init__.py index 7a8df98f..3acc363d 100644 --- a/src/openarmature/checkpoint/__init__.py +++ b/src/openarmature/checkpoint/__init__.py @@ -1,15 +1,23 @@ -"""openarmature.checkpoint — checkpointing capability per spec proposal 0008. +# Spec mapping: this package implements the checkpointing capability +# from pipeline-utilities (spec proposal 0008). +# - Public surface satisfies §10.1 (Checkpointer Protocol), +# §10.2 (record types), §10.10 (error categories). +# - Save fires at completed events for outermost-graph + subgraph- +# internal + fan-out nodes per §10.3. +# - Resume via ``invoke(resume_invocation=...)`` restores per §10.4. + +"""openarmature.checkpoint — checkpointing capability. Public surface: the typed :class:`Checkpointer` Protocol, :class:`CheckpointRecord` / :class:`NodePosition` / -:class:`CheckpointSummary` shapes, the three §10.10 error categories, +:class:`CheckpointSummary` shapes, the checkpoint error categories, and two reference backends (in-memory and SQLite). Users register a backend at graph build time via ``GraphBuilder.with_checkpointer(...)``; the engine then fires saves at every ``completed`` event for outermost-graph nodes and -subgraph-internal nodes per §10.3, and ``invoke(resume_invocation=X)`` -loads + restores from a prior record per §10.4. +subgraph-internal nodes, and ``invoke(resume_invocation=X)`` loads + +restores from a prior record. """ from .backends import InMemoryCheckpointer, SerializationMode, SQLiteCheckpointer diff --git a/src/openarmature/checkpoint/backends/__init__.py b/src/openarmature/checkpoint/backends/__init__.py index 6261d5be..9b985ada 100644 --- a/src/openarmature/checkpoint/backends/__init__.py +++ b/src/openarmature/checkpoint/backends/__init__.py @@ -1,3 +1,8 @@ +# Spec: pipeline-utilities §10.11 enumerates the reference backends +# (in-memory + SQLite shipped here) and names sibling-package adapters +# for Temporal / DBOS / Restate / Redis as informative future work, +# not part of this package. + """Concrete Checkpointer backends. Each backend satisfies the @@ -5,7 +10,7 @@ catalog ships :class:`InMemoryCheckpointer` (no durability; tests + short-lived runs) and :class:`SQLiteCheckpointer` (single-host durable). Sibling-package adapters for Temporal, DBOS, Restate, and -Redis are informative per spec §10.11 — not specified here. +Redis are out of scope here. Users typically import from the package root:: diff --git a/src/openarmature/checkpoint/backends/memory.py b/src/openarmature/checkpoint/backends/memory.py index 9fa99a98..1c4347bd 100644 --- a/src/openarmature/checkpoint/backends/memory.py +++ b/src/openarmature/checkpoint/backends/memory.py @@ -1,4 +1,6 @@ -"""In-memory Checkpointer (spec §10.11). +# Spec: one of the reference backends listed in pipeline-utilities §10.11. + +"""In-memory Checkpointer. Keeps records in a Python ``dict`` keyed by ``invocation_id``. NOT durable across process crashes — useful for tests, short-lived runs, diff --git a/src/openarmature/checkpoint/backends/sqlite.py b/src/openarmature/checkpoint/backends/sqlite.py index 9735fb70..d8065bae 100644 --- a/src/openarmature/checkpoint/backends/sqlite.py +++ b/src/openarmature/checkpoint/backends/sqlite.py @@ -1,4 +1,6 @@ -"""SQLite-backed Checkpointer (spec §10.11). +# Spec: one of the reference backends listed in pipeline-utilities §10.11. + +"""SQLite-backed Checkpointer. Persists records to a SQLite database with WAL mode enabled. Durable across process crashes within a single host. One row per diff --git a/src/openarmature/checkpoint/errors.py b/src/openarmature/checkpoint/errors.py index f1459b1b..380b97e7 100644 --- a/src/openarmature/checkpoint/errors.py +++ b/src/openarmature/checkpoint/errors.py @@ -1,10 +1,17 @@ -"""Errors raised by the checkpointing layer (spec pipeline-utilities §10.10). +# Spec mapping: this module realizes the three canonical +# checkpoint-error categories from pipeline-utilities §10.10. None +# inherits from :class:`graph.errors.RuntimeGraphError` because these +# errors are raised outside a node's execution scope — they don't fit +# the graph-engine §4 runtime-error contract that mandates a +# ``recoverable_state`` attribute. + +"""Errors raised by the checkpointing layer. Three canonical categories. None inherits from :class:`openarmature.graph.errors.RuntimeGraphError` because checkpoint errors are raised outside a node's execution scope (during resume load, during a save call, or during record-shape validation) — they -don't fit the §4 runtime-error contract that mandates a +don't fit the runtime-error contract that mandates a ``recoverable_state`` attribute. """ @@ -13,7 +20,8 @@ class CheckpointError(Exception): """Base for all checkpoint errors. Each subclass carries a - ``category`` class attribute matching the canonical §10.10 string.""" + ``category`` class attribute matching its canonical category + string.""" category: str @@ -33,11 +41,10 @@ def __init__(self, invocation_id: str) -> None: class CheckpointSaveFailed(CheckpointError): """Raised when ``Checkpointer.save`` itself raises during a - ``completed`` event handler. Per spec §10.10 the engine's behavior - on save failure is implementation-defined; this implementation - raises to the caller of ``invoke()`` immediately and does NOT - retry the save itself (documented on - :meth:`CompiledGraph.invoke`).""" + ``completed`` event handler. Engine behavior on save failure is + implementation-defined; this implementation raises to the caller + of ``invoke()`` immediately and does NOT retry the save itself + (documented on :meth:`CompiledGraph.invoke`).""" category = "checkpoint_save_failed" diff --git a/src/openarmature/checkpoint/protocol.py b/src/openarmature/checkpoint/protocol.py index 8cdd938b..6ca11dd9 100644 --- a/src/openarmature/checkpoint/protocol.py +++ b/src/openarmature/checkpoint/protocol.py @@ -1,4 +1,13 @@ -"""Checkpointer Protocol + record types (spec pipeline-utilities §10.1, §10.2). +# Spec mapping (pipeline-utilities): +# - Module realizes §10.1 (Checkpointer Protocol) + §10.2 (record types: +# CheckpointRecord, NodePosition, CheckpointSummary). +# - Save fires per §10.3 (outermost-graph + subgraph-internal + fan-out +# completed events). +# - Fan-out instance-internal events do NOT save in v1 per §10.7 +# (atomic-restart contract); proposal 0009 specifies the future +# per-instance variant. + +"""Checkpointer Protocol + record types. The :class:`Checkpointer` Protocol is the persistence seam between the engine's save/resume machinery and a concrete backend (in-memory, @@ -10,8 +19,8 @@ A :class:`CheckpointRecord` is a frozen, hashable snapshot of one invocation's progress at one save point. The engine produces one record per ``completed`` event for outermost-graph nodes and -subgraph-internal nodes (per §10.3). Fan-out instance internal events -do NOT produce records in v1 (per §10.7's atomic-restart contract). +subgraph-internal nodes. Fan-out instance internal events do NOT +produce records in the shipping version (atomic-restart contract). The :data:`CHECKPOINT_SCHEMA_VERSION` constant is the single source of truth for the persisted record shape — bump it whenever the field @@ -31,11 +40,17 @@ # fan-out resume) is the concrete near-term candidate, since it # populates ``fan_out_progress`` and the load path will need to # distinguish v1 records (where it's None) from v2 records (where it -# carries instance-level progress data). Backends that reject -# mismatches MUST raise CheckpointRecordInvalid per §10.10. +# carries instance-level progress data). Backends that reject mismatches +# MUST raise CheckpointRecordInvalid per spec §10.10. CHECKPOINT_SCHEMA_VERSION = "1" +# Spec: realizes pipeline-utilities §10.2 NodePosition. Field semantics +# tied to graph-engine §6 NodeEvent (step shared; namespace here omits +# the node's own name where NodeEvent.namespace includes it). +# ``fan_out_index`` is part of the shape so a future v2 per-instance +# fan-out resume mode can populate it without a record-shape migration +# (proposal 0009). @dataclass(frozen=True) class NodePosition: """A single completed-node coordinate in the resume map. @@ -45,23 +60,24 @@ class NodePosition: derivation relies on ``set`` membership to skip nodes that have already completed. - Per spec §10.2: - - ``namespace`` is the chain of containing-graph node names from + Fields: + + - ``namespace`` — chain of containing-graph node names from outermost down to (but **not including**) this node. Empty for outermost-graph nodes; one entry for subgraph-internal nodes; two entries when nested two deep, and so on. Distinct from - graph-engine §6's NodeEvent namespace which includes the node's - own name — there NodeEvent.namespace == NodePosition.namespace + - (NodePosition.node_name,). - - ``node_name`` is the node's local name in its containing graph. - - ``step`` is the monotonic step counter at the time the node - completed (shared with §6 NodeEvent.step). - - ``attempt_index`` is the 0-based retry attempt index. The final + ``NodeEvent.namespace`` which includes the node's own name — + ``NodeEvent.namespace == NodePosition.namespace + + (NodePosition.node_name,)``. + - ``node_name`` — the node's local name in its containing graph. + - ``step`` — the monotonic step counter at the time the node + completed (shared with ``NodeEvent.step``). + - ``attempt_index`` — 0-based retry attempt index. The final successful attempt's index is what gets recorded. - - ``fan_out_index`` is populated only for events from inside a - fan-out instance — but per §10.3 those events do NOT produce - records in v1. The field is part of the position shape so v2 - per-instance fan-out resume can populate it without a + - ``fan_out_index`` — populated only for events from inside a + fan-out instance. Those events do NOT produce records in the + shipping version; the field is part of the position shape so a + future per-instance fan-out resume can populate it without a record-shape migration. """ @@ -72,15 +88,18 @@ class NodePosition: fan_out_index: int | None = None +# Spec: realizes pipeline-utilities §10.2 CheckpointRecord. +# ``fan_out_progress`` is reserved for proposal 0009 (per-instance +# fan-out resume); always ``None`` in the shipping version. @dataclass(frozen=True) class CheckpointRecord: - """One invocation's progress at one save point (spec §10.2). + """One invocation's progress at one save point. Frozen — backends MUST treat the record as immutable; the engine builds a fresh record per ``completed`` event rather than mutating - a shared one. The ``fan_out_progress`` field is reserved for the - v2 per-instance fan-out resume follow-on; in v1 it is always - ``None``. + a shared one. The ``fan_out_progress`` field is reserved for a + future per-instance fan-out resume mode; in the shipping version + it is always ``None``. """ invocation_id: str @@ -93,14 +112,16 @@ class CheckpointRecord: fan_out_progress: None = field(default=None) +# Spec: realizes pipeline-utilities §10.1 CheckpointSummary. The four +# declared fields are the spec-mandated minimum; implementations MAY +# add backend-specific fields beyond these. @dataclass(frozen=True) class CheckpointSummary: """Lightweight record-level metadata returned by - :meth:`Checkpointer.list` (spec §10.1). + :meth:`Checkpointer.list`. Implementations MAY add backend-specific fields; the four declared - here are the spec-mandated minimum and form the cross-backend - portable subset callers can rely on. + here are the cross-backend portable subset callers can rely on. """ invocation_id: str @@ -122,8 +143,11 @@ class CheckpointFilter: correlation_id: str | None = None +# Spec: realizes pipeline-utilities §10.1 Checkpointer Protocol. Save +# semantics are synchronous-by-contract per §10.3; resume on missing +# record raises ``CheckpointNotFound`` per §10.4 step 1. class Checkpointer(Protocol): - """Persistence seam for graph invocations (spec §10.1). + """Persistence seam for graph invocations. Implementations MUST be safe to share across concurrent invocations of the same graph (the engine does not serialize diff --git a/src/openarmature/graph/__init__.py b/src/openarmature/graph/__init__.py index 33b37cd9..85c2cb0b 100644 --- a/src/openarmature/graph/__init__.py +++ b/src/openarmature/graph/__init__.py @@ -1,9 +1,12 @@ +# Spec: this package implements the graph-engine capability. Compile-time +# and runtime error categories come from graph-engine §2 and §4. + """Public API for the OpenArmature graph engine. -Re-exports the surface a user touches when building and running a graph: the -state schema base, reducers, the builder/compiled pair, edge primitives and -the END sentinel, the node/subgraph/projection seams, and the canonical -compile-time and runtime error categories from spec §2 and §4. +Re-exports the surface a user touches when building and running a +graph: the state schema base, reducers, the builder/compiled pair, +edge primitives and the END sentinel, the node/subgraph/projection +seams, and the canonical compile-time and runtime error categories. """ from .builder import GraphBuilder diff --git a/src/openarmature/graph/builder.py b/src/openarmature/graph/builder.py index 6cd17487..c9302721 100644 --- a/src/openarmature/graph/builder.py +++ b/src/openarmature/graph/builder.py @@ -1,8 +1,8 @@ """Graph builder: mutable construction → compile to immutable `CompiledGraph`. -Per spec §2: compilation MUST fail if the graph has no declared entry, -unreachable nodes, dangling edges, a node with more than one outgoing edge, -or a field with more than one declared reducer. +Compilation MUST fail if the graph has no declared entry, unreachable +nodes, dangling edges, a node with more than one outgoing edge, or a +field with more than one declared reducer. `GraphBuilder[StateT]` is parameterized on the graph's state type. Node functions, conditional-edge functions, and the returned `CompiledGraph[StateT]` @@ -109,23 +109,22 @@ def add_fan_out_node[ChildT: State]( errors_field: str | None = None, middleware: Iterable[Middleware] | None = None, ) -> Self: - """Register a fan-out node per pipeline-utilities §9. + """Register a fan-out node. Validates configuration at registration time: - - Exactly one of ``items_field`` or ``count`` MUST be specified - (``fan_out_count_mode_ambiguous`` otherwise). - - ``items_field`` MUST refer to a list-typed field on the parent - state schema (``fan_out_field_not_list`` otherwise). - - ``items_field`` mode requires ``item_field``; ``count`` mode - forbids ``item_field``. + - Exactly one of ``items_field`` or ``count`` MUST be + specified (``fan_out_count_mode_ambiguous`` otherwise). + - ``items_field`` MUST refer to a list-typed field on the + parent state schema (``fan_out_field_not_list`` otherwise). + - ``items_field`` mode requires ``item_field``; ``count`` + mode forbids ``item_field``. - ``on_empty`` and ``error_policy`` MUST be one of the - spec-defined string literals. + permitted string literals (``"raise"`` / ``"noop"`` and + ``"fail_fast"`` / ``"collect"`` respectively). - ``inputs`` / ``extra_outputs`` / ``count_field`` field references go through the existing ``mapping_references_undeclared_field`` rule. - - See spec §9 for full field semantics. """ if name in self._nodes: raise ValueError(f"node {name!r} already declared") @@ -240,13 +239,14 @@ def add_fan_out_node[ChildT: State]( return self def with_checkpointer(self, checkpointer: Checkpointer) -> Self: - """Register a Checkpointer for the compiled graph (spec §10.1.1). + """Register a Checkpointer for the compiled graph. At most one Checkpointer per graph; calling - ``with_checkpointer`` again replaces the previously-stored one. - Pass the result of :meth:`compile` to :meth:`CompiledGraph.invoke` - as usual; the engine fires saves at every ``completed`` event - for outermost-graph and subgraph-internal nodes per §10.3. + ``with_checkpointer`` again replaces the previously-stored + one. Pass the result of :meth:`compile` to + :meth:`CompiledGraph.invoke` as usual; the engine fires saves + at every ``completed`` event for outermost-graph and + subgraph-internal nodes. """ self._checkpointer = checkpointer return self @@ -254,10 +254,10 @@ def with_checkpointer(self, checkpointer: Checkpointer) -> Self: def add_middleware(self, middleware: Middleware) -> Self: """Register a per-graph middleware applied to every node in this graph. - Per spec pipeline-utilities §3: per-graph middleware composes - OUTSIDE per-node middleware. Calling order is preserved - (outer-to-inner) — earlier ``add_middleware`` calls produce - outer layers in the runtime chain. + Per-graph middleware composes OUTSIDE per-node middleware. + Calling order is preserved (outer-to-inner) — earlier + ``add_middleware`` calls produce outer layers in the runtime + chain. """ self._middleware.append(middleware) return self diff --git a/src/openarmature/graph/compiled.py b/src/openarmature/graph/compiled.py index 2d245675..6234a42d 100644 --- a/src/openarmature/graph/compiled.py +++ b/src/openarmature/graph/compiled.py @@ -1,24 +1,24 @@ """Compiled graph + execute loop. -Per spec §3 Execution model: execution begins at the entry node; each step -runs a node, merges its partial update via per-field reducers, then evaluates -the outgoing edge against the post-update state to choose the next node (or -END to halt). - -Per spec §4 Error semantics: node, edge, reducer, and routing errors carry -recoverable state; state validation errors do not. - -Per spec v0.6.0 §6 Observer hooks: each node attempt produces a -started/completed event PAIR. The engine dispatches the started event -before invoking the wrapped node function and the completed event after -the reducer merge succeeds (with `post_state` populated) or after the -node, reducer, or state validation fails (with `error` populated). -Routing errors do NOT produce their own event pair — they arise after -the preceding node's completed event has already been dispatched. - -`CompiledGraph[StateT]` and `_merge_partial[StateT]` carry the concrete state -subclass through to `invoke()`'s return type, so consumers don't need -`cast(MyState, ...)` at the call site. +Execution begins at the entry node; each step runs a node, merges +its partial update via per-field reducers, then evaluates the +outgoing edge against the post-update state to choose the next node +(or END to halt). + +Node, edge, reducer, and routing errors carry recoverable state; +state validation errors do not. + +Each node attempt produces a started/completed event PAIR. The +engine dispatches the started event before invoking the wrapped node +function and the completed event after the reducer merge succeeds +(with ``post_state`` populated) or after the node, reducer, or state +validation fails (with ``error`` populated). Routing errors do NOT +produce their own event pair — they land on the preceding node's +``completed`` event with ``error`` populated. + +``CompiledGraph[StateT]`` and ``_merge_partial[StateT]`` carry the +concrete state subclass through to ``invoke()``'s return type, so +consumers don't need ``cast(MyState, ...)`` at the call site. """ from __future__ import annotations @@ -158,9 +158,9 @@ def _merge_partial[StateT: State]( ) -> StateT: """Apply per-field reducers to merge a node's partial update into prior state. - Re-validates the resulting state against the schema (per spec §2 SHOULD - validate at node boundaries). Wraps reducer failures as `ReducerError` and - schema failures as `StateValidationError`. + Re-validates the resulting state against the schema (validation + happens at node boundaries). Wraps reducer failures as + ``ReducerError`` and schema failures as ``StateValidationError``. """ new_values = prior.model_dump() @@ -285,43 +285,42 @@ def attach_observer( ) -> RemoveHandle: """Register a graph-attached observer. - Per spec v0.6.0 §6: graph-attached observers fire on every invocation - of this graph until removed — including when this graph runs as a - subgraph inside a parent. Returns a `RemoveHandle` whose `.remove()` - method detaches the observer; idempotent. + Graph-attached observers fire on every invocation of this + graph until removed — including when this graph runs as a + subgraph inside a parent. Returns a ``RemoveHandle`` whose + ``.remove()`` method detaches the observer; idempotent. - `phases` selects the phase strings (`"started"`, `"completed"`) the - observer subscribes to; default is both. An empty `phases` set - raises `ValueError` at registration time. + ``phases`` selects the phase strings (``"started"``, + ``"completed"``) the observer subscribes to; default is both. + An empty ``phases`` set raises ``ValueError`` at registration + time. - Per spec: changes to the registered set during a graph run do NOT - take effect until the next invocation. The set of observers - delivering events for an in-flight invocation is fixed at the point - the invocation begins. + Changes to the registered set during a graph run do NOT take + effect until the next invocation. The set of observers + delivering events for an in-flight invocation is fixed at + the point the invocation begins. """ subscribed = _coerce_subscribed(observer, phases=phases) self._attached_observers.append(subscribed) return RemoveHandle(_observers=self._attached_observers, _observer=subscribed) # ------------------------------------------------------------------ - # Checkpointer registration (spec pipeline-utilities §10.1.1) + # Checkpointer registration # ------------------------------------------------------------------ def attach_checkpointer(self, checkpointer: Checkpointer | None) -> None: - """Register a Checkpointer for this graph (spec §10.1.1). - - Pass ``None`` to clear a previously-registered backend. Without - a registered Checkpointer the engine never calls ``save()`` and - ``invoke(resume_invocation=...)`` raises - ``checkpoint_not_found`` — the default-off behavior matches the - broader OA pattern of "the contract is normative; the - activation is an explicit choice." - - At most one Checkpointer per graph (§10.1.1). Calling - ``attach_checkpointer`` again replaces the previously-registered - one; multi-backend fan-out is the user's responsibility (wrap - two underlying Checkpointers behind a custom protocol-conforming - implementation if needed). + """Register a Checkpointer for this graph. + + Pass ``None`` to clear a previously-registered backend. + Without a registered Checkpointer the engine never calls + ``save()`` and ``invoke(resume_invocation=...)`` raises + ``checkpoint_not_found``. + + At most one Checkpointer per graph. Calling + ``attach_checkpointer`` again replaces the previously- + registered one; multi-backend fan-out is the user's + responsibility (wrap two underlying Checkpointers behind a + custom protocol-conforming implementation if needed). """ self._checkpointer_slot[0] = checkpointer @@ -334,14 +333,15 @@ async def drain(self) -> None: """Await delivery of every observer event produced by prior invocations of this graph. - Per spec v0.6.0 §6: callers running in short-lived processes (scripts, - serverless functions, CLIs) MUST use drain to avoid losing observer + Callers running in short-lived processes (scripts, serverless + functions, CLIs) MUST use drain to avoid losing observer events that were dispatched but not yet delivered. - Only events dispatched before this call are awaited; events from - invocations started concurrently with drain may or may not be - included. Subgraph events from active invocations are part of the - parent invocation's worker and are covered automatically. + Only events dispatched before this call are awaited; events + from invocations started concurrently with drain may or may + not be included. Subgraph events from active invocations are + part of the parent invocation's worker and are covered + automatically. **Unbounded by design.** Drain blocks until every queued event has been delivered to every subscribed observer. A slow, hung, or @@ -371,35 +371,36 @@ async def invoke( correlation_id: str | None = None, resume_invocation: str | None = None, ) -> StateT: - """Run the graph from `initial_state` to END and return the final state. + """Run the graph from ``initial_state`` to END and return the + final state. - Optional `observers` are invocation-scoped — they fire only for this - run, after all graph-attached observers (including subgraph-attached - ones for events originating in subgraphs) per spec v0.6.0 §6. + Optional ``observers`` are invocation-scoped — they fire only + for this run, after all graph-attached observers (including + subgraph-attached ones for events originating in subgraphs). - Each entry in `observers` may be either a bare `Observer` callable - (subscribes to both phases) or a `SubscribedObserver` wrapping an - observer with an explicit `phases` set. + Each entry in ``observers`` may be either a bare ``Observer`` + callable (subscribes to both phases) or a ``SubscribedObserver`` + wrapping an observer with an explicit ``phases`` set. - Per spec v0.6.0 §6: this method returns as soon as the graph - execution loop completes, regardless of whether the observer - delivery queue has finished processing every dispatched event. Use - `await compiled.drain()` if you need delivery-completion guarantees. + This method returns as soon as the graph execution loop + completes, regardless of whether the observer delivery queue + has finished processing every dispatched event. Use + ``await compiled.drain()`` if you need delivery-completion + guarantees. - **Checkpointing (pipeline-utilities §10):** + **Checkpointing.** - ``correlation_id`` is the per-invocation cross-backend join - key (see observability §3 in spec v0.7+). Caller-supplied or - auto-generated UUIDv4 when absent. Preserved unchanged across - ``resume_invocation``. - - ``resume_invocation`` names a prior invocation_id to resume - from. Requires a registered Checkpointer; raises + key. Caller-supplied or auto-generated UUIDv4 when absent. + Preserved unchanged across ``resume_invocation``. + - ``resume_invocation`` names a prior ``invocation_id`` to + resume from. Requires a registered Checkpointer; raises ``CheckpointNotFound`` when the backend has no record for the supplied id, ``CheckpointRecordInvalid`` when the loaded record's schema is incompatible. Resume mints a NEW - ``invocation_id`` per §10.4 — each attempt is its own - invocation in the observability sense; the - ``correlation_id`` is the cross-attempt join key. + ``invocation_id`` — each attempt is its own invocation in + the observability sense; the ``correlation_id`` is the + cross-attempt join key. - **Save-failure policy.** This implementation raises ``CheckpointSaveFailed`` to the caller of ``invoke()`` immediately when ``Checkpointer.save`` raises; saves are @@ -407,7 +408,7 @@ async def invoke( own retry logic if transient backend failures should be reattempted. - Raises one of the runtime error categories from spec §4 on failure. + Raises one of the runtime error categories on failure. """ invocation_scoped = tuple(_coerce_subscribed(o) for o in (observers or ())) diff --git a/src/openarmature/graph/edges.py b/src/openarmature/graph/edges.py index 12c7e504..2639a347 100644 --- a/src/openarmature/graph/edges.py +++ b/src/openarmature/graph/edges.py @@ -1,12 +1,16 @@ +# Spec: realizes graph-engine §2 (Edge, END concepts). END is a +# distinct engine sentinel (not a reserved node name) — using the +# literal string ``"END"`` as a target fails ``DanglingEdge`` at compile. + """Edges and the END sentinel. -Per spec §2 Concepts (Edge, END): edges are static or conditional; each node -has exactly one outgoing edge. END is a distinct engine sentinel (not a -reserved node name) used as a routing target to halt execution. +Edges are static or conditional; each node has exactly one outgoing +edge. `END` is a distinct engine sentinel used as a routing target +to halt execution. -`ConditionalEdge` is generic on the outer graph's state type so the routing -function's parameter is typed against the user's `State` subclass — not -`Any` — at type-check time. +`ConditionalEdge` is generic on the outer graph's state type so the +routing function's parameter is typed against the user's `State` +subclass — not `Any` — at type-check time. """ from collections.abc import Callable diff --git a/src/openarmature/graph/errors.py b/src/openarmature/graph/errors.py index b3e2b8dd..c9f9b99d 100644 --- a/src/openarmature/graph/errors.py +++ b/src/openarmature/graph/errors.py @@ -1,9 +1,15 @@ +# Spec: realizes graph-engine §2 (compile-time errors) and §4 +# (runtime errors). The four runtime categories other than +# ``state_validation_error`` carry a ``recoverable_state`` attribute +# per §4. Fan-out-specific error categories (both compile and runtime) +# mirror pipeline-utilities §9. + """Errors raised by the graph engine. -Each error class carries a `category` class attribute matching the canonical -identifier mandated by spec §2 (compile-time) and §4 (runtime). Per spec §4, -the four runtime categories other than state_validation_error MUST carry a -recoverable_state attribute. +Each error class carries a ``category`` class attribute matching the +canonical category identifier. The four runtime categories other +than ``state_validation_error`` carry a ``recoverable_state`` +attribute. """ from typing import Any @@ -13,7 +19,7 @@ class GraphError(Exception): """Base for all graph-engine errors.""" -# ===== Compile-time errors (spec §2) ===== +# ===== Compile-time errors ===== class CompileError(GraphError): @@ -63,8 +69,9 @@ def __init__(self, field_name: str) -> None: class MappingReferencesUndeclaredField(CompileError): - """Per spec v0.2.0 §2: a subgraph-as-node `inputs` or `outputs` mapping - names a field that is not declared in the relevant state schema.""" + """Raised when a subgraph-as-node ``inputs`` or ``outputs`` + mapping names a field that is not declared in the relevant state + schema.""" category = "mapping_references_undeclared_field" @@ -76,9 +83,8 @@ def __init__(self, *, direction: str, side: str, field_name: str) -> None: class FanOutCountModeAmbiguous(CompileError): - """Per spec v0.4.0 §9: a fan-out node MUST specify exactly one of - ``items_field`` or ``count``. Specifying both or neither is a - compile error.""" + """Raised when a fan-out node specifies both ``items_field`` and + ``count``, or neither. Exactly one is required.""" category = "fan_out_count_mode_ambiguous" @@ -88,8 +94,8 @@ def __init__(self, node_name: str, message: str) -> None: class FanOutFieldNotList(CompileError): - """Per spec v0.4.0 §9: a fan-out node's ``items_field`` MUST refer - to a declared list-typed field on the parent state schema.""" + """Raised when a fan-out node's ``items_field`` does not refer to + a declared list-typed field on the parent state schema.""" category = "fan_out_field_not_list" @@ -99,12 +105,12 @@ def __init__(self, node_name: str, field_name: str) -> None: self.field_name = field_name -# ===== Runtime errors (spec §4) ===== +# ===== Runtime errors ===== class RuntimeGraphError(GraphError): """Base for runtime errors. The four non-validation categories carry a - `recoverable_state` attribute per spec §4.""" + ``recoverable_state`` attribute.""" category: str @@ -177,14 +183,13 @@ def __init__(self, source_node: str, returned: object, recoverable_state: Any) - class FanOutEmpty(NodeException): - """Per spec v0.4.0 §9.1: a fan-out node resolved to zero instances - while its ``on_empty`` config was ``"raise"`` (the default). - - Per fixture 023's expected shape, this surfaces as a regular §4 - ``node_exception`` (so it integrates with the existing error - propagation and recoverable-state machinery) but exposes an - additional ``fan_out_category`` attribute so callers can - distinguish empty-fan-out from generic node failures. + """Raised when a fan-out node resolves to zero instances while + its ``on_empty`` config is ``"raise"`` (the default). + + Surfaces as a regular ``node_exception`` (so it integrates with + the existing error propagation and recoverable-state machinery) + but exposes an additional ``fan_out_category`` attribute so + callers can distinguish empty-fan-out from generic node failures. """ fan_out_category = "fan_out_empty" @@ -201,9 +206,10 @@ def __init__(self, node_name: str, recoverable_state: Any) -> None: class FanOutInvalidCount(NodeException): - """Per spec v0.4.0 §9.1: a fan-out node's ``count`` callable returned - a negative integer at runtime. Same node_exception shape as - FanOutEmpty, with ``fan_out_category = "fan_out_invalid_count"``.""" + """Raised when a fan-out node's ``count`` callable returns a + negative integer at runtime. Same node-exception shape as + :class:`FanOutEmpty`, with + ``fan_out_category = "fan_out_invalid_count"``.""" fan_out_category = "fan_out_invalid_count" @@ -214,9 +220,9 @@ def __init__(self, node_name: str, returned: int, recoverable_state: Any) -> Non class FanOutInvalidConcurrency(NodeException): - """Per spec v0.4.0 §9.2: a fan-out node's ``concurrency`` callable - returned zero or a negative integer at runtime. Same node_exception - shape as FanOutEmpty.""" + """Raised when a fan-out node's ``concurrency`` callable returns + zero or a negative integer at runtime. Same node-exception shape + as :class:`FanOutEmpty`.""" fan_out_category = "fan_out_invalid_concurrency" @@ -232,8 +238,9 @@ def __init__(self, node_name: str, returned: int | None, recoverable_state: Any) class StateValidationError(RuntimeGraphError): """State failed schema validation at a graph boundary. - Per spec §4 this category does NOT carry recoverable_state — at entry there - is no prior state to recover; at exit the failing state IS the final state. + Unlike the other runtime errors, this category does NOT carry + ``recoverable_state`` — at entry there is no prior state to + recover; at exit the failing state IS the final state. """ category = "state_validation_error" diff --git a/src/openarmature/graph/events.py b/src/openarmature/graph/events.py index 67595c41..599cb2b6 100644 --- a/src/openarmature/graph/events.py +++ b/src/openarmature/graph/events.py @@ -1,10 +1,14 @@ +# Spec: realizes graph-engine §6 (started/completed event pair model +# from proposal 0005, v0.6.0). FanOutEventConfig is the fan-out node +# event payload added by proposal 0013 (v0.10.0). + """Node-boundary observer events. -Per spec v0.6.0 §6 (proposal 0005): each node attempt produces a -started/completed event PAIR. The engine dispatches the started event -before invoking the wrapped node function and the completed event after -the reducer merge succeeds (with `post_state` populated) or after the -node, reducer, or state validation fails (with `error` populated). +Each node attempt produces a started/completed event PAIR. The engine +dispatches the started event before invoking the wrapped node function +and the completed event after the reducer merge succeeds (with +``post_state`` populated) or after the node, reducer, or state +validation fails (with ``error`` populated). Frozen dataclass — observers receive a snapshot, not a live handle. """ @@ -16,10 +20,18 @@ from .state import State +# Spec: realizes observability §5.4 fan-out attributes via the +# event-payload mechanism added by proposal 0013 (v0.10.0). Backend +# observers cache ``parent_node_name`` off the fan-out node's +# started event and apply it on every per-instance span they +# synthesize (observability §5.4 mandates +# ``openarmature.fan_out.parent_node_name`` on per-instance spans). @dataclass(frozen=True) class FanOutEventConfig: - """Spec §6 + §5.4 (per spec proposal 0013, v0.10.0): - fan-out node events carry the resolved configuration so backend + """Resolved fan-out configuration carried on a fan-out node's + own events. + + Fan-out node events carry the resolved configuration so backend observers can attribute the fan-out node span (``item_count`` / ``concurrency`` / ``error_policy``) and synthesize per-instance spans with the right ``parent_node_name``. @@ -33,24 +45,18 @@ class FanOutEventConfig: Field shapes: - ``item_count`` — non-negative int. The resolved instance count - per pipeline-utilities §9 (matches ``count_field`` value when - configured; matches ``len(items_field)`` in items_field mode). - - ``concurrency`` — positive int OR ``None`` (unbounded). Per - pipeline-utilities §9.2: zero or negative is rejected at config - resolution time as ``fan_out_invalid_concurrency``. Backend - mappings translate ``None`` to a sentinel at the attribute layer - (e.g., ``openarmature.fan_out.concurrency = 0`` per - observability §5.4) — that translation is observer-internal, - not engine-internal. - - ``error_policy`` — one of ``"fail_fast"`` or ``"collect"`` per - pipeline-utilities §9.4. + (matches ``count_field`` value when configured; matches + ``len(items_field)`` in items_field mode). + - ``concurrency`` — positive int OR ``None`` (unbounded). Zero or + negative is rejected at config resolution time as + ``fan_out_invalid_concurrency``. Backend mappings may translate + ``None`` to a sentinel at the attribute layer (e.g., + ``openarmature.fan_out.concurrency = 0``) — that translation is + observer-internal, not engine-internal. + - ``error_policy`` — one of ``"fail_fast"`` or ``"collect"``. - ``parent_node_name`` — the fan-out node's name in the parent graph. Carried here for caching by backend observers when - attributing per-instance spans (§5.4 mandates - ``openarmature.fan_out.parent_node_name`` on per-instance spans; - the engine surfaces the name once on the fan-out node's started - event, the observer caches and applies on every per-instance - span it synthesizes). + attributing per-instance spans. All four fields MUST be present when ``fan_out_config`` is populated. Only ``concurrency`` is nullable. @@ -62,53 +68,60 @@ class FanOutEventConfig: parent_node_name: str +# Spec: realizes graph-engine §6 NodeEvent (started/completed pair +# model from proposal 0005, v0.6.0). The ``checkpoint_saved`` phase +# is the Python shape for §10.8 save events (§10.8 SHOULDs an event +# emit but leaves the shape implementation-defined). ``fan_out_config`` +# is the observability §5.4 / proposal 0013 (v0.10.0) addition. @dataclass(frozen=True) class NodeEvent: """A single node-boundary event delivered to observers. - Per spec v0.6.0 §6: - - - `phase` is `"started"` (dispatched before the node runs) or - `"completed"` (dispatched after the node returns or raises and the - merge runs/fails). Each node attempt produces exactly one of each - in that order. Per pipeline-utilities §10.8, the engine ALSO - dispatches a `"checkpoint_saved"` event on the same shape after - a successful Checkpointer.save call — observers MUST opt in - explicitly via `phases={"checkpoint_saved"}` to receive these - (default subscription is `{"started", "completed"}` only, so + - ``phase`` is ``"started"`` (dispatched before the node runs) or + ``"completed"`` (dispatched after the node returns or raises + and the merge runs/fails). Each node attempt produces exactly + one of each in that order. The engine ALSO dispatches a + ``"checkpoint_saved"`` event on the same shape after a + successful ``Checkpointer.save`` call — observers MUST opt in + explicitly via ``phases={"checkpoint_saved"}`` to receive these + (default subscription is ``{"started", "completed"}`` only, so legacy observers don't see them). - - `node_name` is the name under which this node was registered in its - immediate containing graph. - - `namespace` is an ordered sequence of node names from the outermost - graph down to this node. For a node in the outermost graph, - `namespace` is `(node_name,)`. For nested subgraphs, the chain - extends. - - `step` is a monotonically-increasing counter starting at 0, scoped - to a single outermost-invocation. Subgraph-internal nodes increment - the same counter. The started/completed pair for one attempt share - the same step. - - `pre_state` is the state the node received, before reducer merge. - Populated on both phases (identical across the pair). - - `post_state` is the state after the node's partial update merged - successfully. Populated only on `completed` events that succeeded. - - `error` is the wrapped runtime error (NodeException, ReducerError, - or StateValidationError) when the node failed. Populated only on - `completed` events that failed. - - `parent_states` carries one state snapshot per containing graph, - outermost first; for a node in the outermost graph it's an empty - tuple. Invariant: `len(parent_states) == len(namespace) - 1`. - - `attempt_index` is the 0-based index of this attempt among any - retries. `0` for nodes not wrapped by retry middleware. - - `fan_out_index` is the 0-based index of this fan-out instance among - its siblings. `None` for nodes not inside a fan-out. - - `fan_out_config` carries resolved fan-out configuration on events - from a fan-out NODE itself (per spec proposal 0013, v0.10.0). See + - ``node_name`` is the name under which this node was registered + in its immediate containing graph. + - ``namespace`` is an ordered sequence of node names from the + outermost graph down to this node. For a node in the outermost + graph, ``namespace`` is ``(node_name,)``. For nested subgraphs, + the chain extends. + - ``step`` is a monotonically-increasing counter starting at 0, + scoped to a single outermost invocation. Subgraph-internal nodes + increment the same counter. The started/completed pair for one + attempt share the same step. + - ``pre_state`` is the state the node received, before reducer + merge. Populated on both phases (identical across the pair). + - ``post_state`` is the state after the node's partial update + merged successfully. Populated only on ``completed`` events + that succeeded. + - ``error`` is the wrapped runtime error (``NodeException``, + ``ReducerError``, or ``StateValidationError``) when the node + failed. Populated only on ``completed`` events that failed. + - ``parent_states`` carries one state snapshot per containing + graph, outermost first; for a node in the outermost graph it's + an empty tuple. Invariant: + ``len(parent_states) == len(namespace) - 1``. + - ``attempt_index`` is the 0-based index of this attempt among + any retries. ``0`` for nodes not wrapped by retry middleware. + - ``fan_out_index`` is the 0-based index of this fan-out instance + among its siblings. ``None`` for nodes not inside a fan-out. + - ``fan_out_config`` carries resolved fan-out configuration on + events from a fan-out NODE itself. See :class:`FanOutEventConfig`. ``None`` on every other event. Invariants: - - On `started` events, `post_state` and `error` MUST both be None. - - On `completed` events, exactly one of `post_state` and `error` is - populated. + + - On ``started`` events, ``post_state`` and ``error`` MUST both + be ``None``. + - On ``completed`` events, exactly one of ``post_state`` and + ``error`` is populated. """ node_name: str diff --git a/src/openarmature/graph/fan_out.py b/src/openarmature/graph/fan_out.py index 97790c4d..1030c9de 100644 --- a/src/openarmature/graph/fan_out.py +++ b/src/openarmature/graph/fan_out.py @@ -1,8 +1,10 @@ +# Spec: realizes pipeline-utilities §9 (fan-out node). + """Fan-out node — parallel per-item / per-count subgraph dispatch. -Per spec pipeline-utilities §9: a fan-out node executes a compiled -subgraph (or async callable) once per item in a designated parent -state field, with instances running concurrently up to a configurable +A fan-out node executes a compiled subgraph (or async callable) once +per item in a designated parent state field, with instances running +concurrently up to a configurable bound, and collects per-instance results back into a parent collection field. @@ -57,9 +59,9 @@ class FanOutConfig: """Frozen configuration for a :class:`FanOutNode`. - See spec §9 for field semantics. Validation happens at builder - compile time (see ``GraphBuilder.add_fan_out_node``); construction - here is unchecked beyond the obvious type-level constraints. + Validation happens at builder compile time (see + ``GraphBuilder.add_fan_out_node``); construction here is + unchecked beyond the obvious type-level constraints. """ subgraph: CompiledGraph[Any] @@ -113,10 +115,10 @@ async def run_with_context( ) -> Mapping[str, Any]: """Execute the fan-out and return the merged partial update. - Per spec §9.1–§9.5: snapshot, resolve count + concurrency, - build per-instance states, run concurrently with the configured - error policy, fan-in collected/extra fields, write count_field - and errors_field if configured. + Snapshot, resolve count + concurrency, build per-instance + states, run concurrently with the configured error policy, + fan-in collected/extra fields, write count_field and + errors_field if configured. ``pre_resolved_count`` / ``pre_resolved_concurrency`` are the proposal-0013 v0.10.0 hooks: when the engine has already diff --git a/src/openarmature/graph/middleware/__init__.py b/src/openarmature/graph/middleware/__init__.py index 2906e8d2..4efdfc42 100644 --- a/src/openarmature/graph/middleware/__init__.py +++ b/src/openarmature/graph/middleware/__init__.py @@ -1,8 +1,8 @@ """Middleware subpackage — protocol + canonical implementations. The ``Middleware`` Protocol and chain-composition machinery live in -:mod:`._core`; the spec-mandated canonical middleware (Retry, Timing) -live in their own modules. The subpackage's public surface is +:mod:`._core`; the canonical middleware (Retry, Timing) live in their +own modules. The subpackage's public surface is re-exported here so callers can write:: from openarmature.graph.middleware import ( diff --git a/src/openarmature/graph/middleware/_core.py b/src/openarmature/graph/middleware/_core.py index 2acbb86f..a436aa60 100644 --- a/src/openarmature/graph/middleware/_core.py +++ b/src/openarmature/graph/middleware/_core.py @@ -1,25 +1,25 @@ """Middleware infrastructure: protocol, chain composition, registration. -Per spec pipeline-utilities §2 Concepts: a middleware is an async callable -with the shape ``(state, next) -> partial_update``. The middleware chain -composes outer-to-inner — the first middleware in the list runs first, +A middleware is an async callable with the shape +``(state, next) -> partial_update``. The middleware chain composes +outer-to-inner — the first middleware in the list runs first, calls ``next(state)`` to invoke the next layer, and so on with the wrapped node at the inner end. Code before ``await next(...)`` is the pre-node phase (running on the way IN); code after is the post-node phase (running on the way OUT). -Per §3 Registration: per-graph middleware composes OUTSIDE per-node -middleware — the runtime chain is +Per-graph middleware composes OUTSIDE per-node middleware — the +runtime chain is ``[per_graph_outer_to_inner...] → [per_node_outer_to_inner...] → node``. -Per §4: middleware does NOT cross the subgraph boundary. The parent's +Middleware does NOT cross the subgraph boundary. The parent's middleware wraps the SubgraphNode dispatch as a single atomic call; the subgraph's own middleware wraps its internal nodes independently. -Per §5: errors raised inside the chain (from the node or from inner -middleware) propagate through ``await next(...)``. Middleware may catch -and recover (returning a partial update) or re-raise. Uncaught exceptions -become a graph-engine §4 ``node_exception`` once they reach the engine. +Errors raised inside the chain (from the node or from inner +middleware) propagate through ``await next(...)``. Middleware may +catch and recover (returning a partial update) or re-raise. Uncaught +exceptions become a ``node_exception`` once they reach the engine. """ from __future__ import annotations @@ -33,13 +33,13 @@ class NextCall(Protocol): """The ``next`` callable a middleware receives. - Calling it with a state invokes the next layer of the chain (or the - wrapped node, at the inner end) and returns the partial update from - that layer. Middleware MAY transform the state passed to ``next`` — - the transformed state flows down the chain but does NOT replace the - engine's pre-merge state at the outermost level (per §2: "the - transformed state is passed to ``next``, NOT to the engine's merge - step"). + Calling it with a state invokes the next layer of the chain (or + the wrapped node, at the inner end) and returns the partial + update from that layer. Middleware MAY transform the state passed + to ``next`` — the transformed state flows down the chain but does + NOT replace the engine's pre-merge state at the outermost level + (the transformed state is passed to ``next``, NOT to the engine's + merge step). """ async def __call__(self, state: Any, /) -> Mapping[str, Any]: @@ -50,9 +50,9 @@ async def __call__(self, state: Any, /) -> Mapping[str, Any]: class Middleware(Protocol): """An async callable that wraps the dispatch of a single node. - Per spec v0.4.0 pipeline-utilities §2: the shape is - ``(state, next) -> partial_update``. The middleware MUST return a - mapping of field names to values — same shape a node returns. It may: + The shape is ``(state, next) -> partial_update``. The middleware + MUST return a mapping of field names to values — same shape a + node returns. It may: - Inspect or transform ``state`` before calling ``next(state)``. - Inspect or transform the partial update returned from ``next``. diff --git a/src/openarmature/graph/middleware/retry.py b/src/openarmature/graph/middleware/retry.py index 14a4503a..204804d6 100644 --- a/src/openarmature/graph/middleware/retry.py +++ b/src/openarmature/graph/middleware/retry.py @@ -1,4 +1,6 @@ -"""Retry middleware (canonical, spec pipeline-utilities §6.1). +# Spec: canonical retry middleware per pipeline-utilities §6.1. + +"""Retry middleware (canonical). Wraps a node's chain with retry-on-transient-error logic. Each retry attempt produces its own ``started``/``completed`` event pair from the @@ -30,14 +32,14 @@ def default_classifier(exc: Exception, _state: Any) -> bool: - """Spec §6.1 default classifier — purely category-based, ignores state. + """Default classifier — purely category-based, ignores state. Returns True if either the exception itself or its ``__cause__`` carries a ``category`` attribute matching ``TRANSIENT_CATEGORIES``. The cause-walking covers the common case of a graph-engine - ``NodeException`` wrapping an llm-provider transient — per the spec: - "a `node_exception` whose `__cause__` is a transient category MUST - be classified as transient." + ``NodeException`` wrapping an llm-provider transient: a + ``node_exception`` whose ``__cause__`` is a transient category + classifies as transient. The ``_state`` parameter is ignored by the default; the leading underscore is the canonical Python convention for "intentionally @@ -63,10 +65,10 @@ def exponential_jitter_backoff( ) -> float: """Default backoff: ``random.uniform(0, min(cap, base * 2**attempt))``. - Per spec §6.1: jitter is mandatory — fixed exponential backoff - causes synchronized retries from many concurrent callers, amplifying + Jitter is mandatory — fixed exponential backoff causes + synchronized retries from many concurrent callers, amplifying rate-limit storms. ``base`` and ``cap`` are configurable; the - spec-mandated defaults are 1.0 and 30.0 seconds. + defaults are 1.0 and 30.0 seconds. """ return random.uniform(0, min(cap, base * (2**attempt))) @@ -93,7 +95,7 @@ def fn(_attempt: int) -> float: class RetryMiddleware: - """Spec §6.1 canonical retry middleware. + """Canonical retry middleware. Configuration: diff --git a/src/openarmature/graph/middleware/timing.py b/src/openarmature/graph/middleware/timing.py index 4e898793..7f978743 100644 --- a/src/openarmature/graph/middleware/timing.py +++ b/src/openarmature/graph/middleware/timing.py @@ -1,17 +1,19 @@ -"""Timing middleware (canonical, spec pipeline-utilities §6.2). +# Spec: canonical timing middleware per pipeline-utilities §6.2. + +"""Timing middleware (canonical). Records wall-clock duration of the wrapped chain (including any inner middleware time, e.g., retries) and dispatches the result to a -user-supplied async callback. Uses ``time.monotonic`` per the spec — -wall-clock time is unreliable across NTP corrections and DST transitions -and would produce negative durations that corrupt downstream metric -pipelines. - -The middleware is constructed with an explicit ``node_name`` because the -§2 ``(state, next)`` shape doesn't expose node identity at call time. -Per-instance clock injection (defaulting to ``time.monotonic``) lets -test fixtures supply a deterministic stub without globally patching -``time.monotonic``, which would also affect asyncio's scheduling layer. +user-supplied async callback. Uses ``time.monotonic`` (monotonic +across NTP corrections and DST transitions, where wall-clock would +produce negative durations that corrupt downstream metric pipelines). + +The middleware is constructed with an explicit ``node_name`` because +the ``(state, next)`` middleware shape doesn't expose node identity +at call time. Per-instance clock injection (defaulting to +``time.monotonic``) lets test fixtures supply a deterministic stub +without globally patching ``time.monotonic``, which would also +affect asyncio's scheduling layer. """ from __future__ import annotations @@ -28,10 +30,9 @@ class TimingRecord: """A single timing measurement produced by ``TimingMiddleware``. - Per spec §6.2: - - - ``node_name``: the node this middleware was attached to (captured - at registration; users supply it explicitly for per-node use). + - ``node_name``: the node this middleware was attached to + (captured at registration; users supply it explicitly for + per-node use). - ``duration_ms``: milliseconds from middleware entry to chain return-or-raise, measured with a monotonic clock. - ``outcome``: one of ``"success"`` or ``"exception"``. @@ -50,16 +51,16 @@ class TimingRecord: class TimingMiddleware: - """Spec §6.2 canonical timing middleware. + """Canonical timing middleware. Records wall-clock duration of the wrapped chain via the host language's monotonic clock (Python's ``time.monotonic``). The callback fires inline before the chain's result returns to the - caller — slow callbacks add to the apparent node duration, so users - SHOULD keep them fast (queue work, defer I/O). + caller — slow callbacks add to the apparent node duration, so + users SHOULD keep them fast (queue work, defer I/O). Errors raised by ``on_complete`` propagate to the engine as a - ``node_exception`` per graph-engine §4. + ``node_exception``. """ def __init__( diff --git a/src/openarmature/graph/nodes.py b/src/openarmature/graph/nodes.py index 5068170d..96d0094e 100644 --- a/src/openarmature/graph/nodes.py +++ b/src/openarmature/graph/nodes.py @@ -1,18 +1,20 @@ +# Spec: realizes graph-engine §2 (Node concept) and pipeline-utilities +# §3 (Registration: per-node middleware). Per-graph middleware composes +# OUTSIDE the per-node list at runtime per §3. + """Graph nodes. -Per spec §2 Concepts (Node): a node is a named unit of work. Nodes MUST be -asynchronous and MUST NOT mutate the state they receive — they return a -partial update which the engine merges via reducers. +A node is a named unit of work. Nodes are asynchronous and don't +mutate the state they receive — they return a partial update which +the engine merges via reducers. -The `Node` Protocol exists so subgraphs can compose as nodes alongside -plain function-backed nodes (see `subgraph.SubgraphNode`). Both are -parameterized on `StateT` so the outer graph's state type flows through -to node functions at type-check time. +The `Node` Protocol exists so subgraphs can compose as nodes +alongside plain function-backed nodes (see `subgraph.SubgraphNode`). +Both are parameterized on `StateT` so the outer graph's state type +flows through to node functions at type-check time. -Per pipeline-utilities §3 Registration, each node carries an optional -ordered tuple of `Middleware` declared at its registration site -(per-node middleware). The engine composes per-graph middleware OUTSIDE -this list at runtime per §3. +Each node carries an optional ordered tuple of `Middleware` declared +at its registration site (per-node middleware). """ from collections.abc import Awaitable, Callable, Mapping @@ -34,7 +36,7 @@ def name(self) -> str: @property def middleware(self) -> tuple[Middleware, ...]: """Per-node middleware applied at this node's registration site, - outer-to-inner. Composed inside any per-graph middleware per §3.""" + outer-to-inner. Composed inside any per-graph middleware.""" raise NotImplementedError async def run(self, state: StateT) -> Mapping[str, Any]: diff --git a/src/openarmature/graph/observer.py b/src/openarmature/graph/observer.py index 3cb19243..b28b749f 100644 --- a/src/openarmature/graph/observer.py +++ b/src/openarmature/graph/observer.py @@ -1,8 +1,8 @@ """Observer hooks: protocol, subscription, delivery queue, per-invocation context. -Per spec v0.6.0 §6 (proposal 0005): each node attempt produces a started/ -completed event pair, and observers register with an optional `phases` -set so they can subscribe to one phase or both. The graph never awaits +Each node attempt produces a started/completed event pair, and +observers register with an optional ``phases`` set so they can +subscribe to one phase or both. The graph never awaits observer processing. This module defines: @@ -50,13 +50,14 @@ async def log_observer(event: NodeEvent) -> None: compiled.attach_observer(log_observer) - Per spec v0.6.0 §6: + Contract: - - Observers MUST be async so the delivery queue can await each one - and coordinate ordering. The graph itself never awaits observers. - - Observers MUST NOT alter state, routing, or any other aspect of - the graph run — read-only side effects (logging, metrics, span - emission) only. + - Observers MUST be async so the delivery queue can await each + one and coordinate ordering. The graph itself never awaits + observers. + - Observers MUST NOT alter state, routing, or any other aspect + of the graph run — read-only side effects (logging, metrics, + span emission) only. The event parameter is positional-only (`event, /`) so structural conformance doesn't pin you to that name — any of `event`, `_event`, @@ -78,8 +79,8 @@ def prepare_sync(self, event: NodeEvent, /) -> None: ... ``prepare_sync`` is **opt-in via ``hasattr``** — no subclass or Protocol method required. Observers that don't define it skip the synchronous prep entirely; observers that do define it run - only for ``"started"``-phase events, errors warned not propagated - (same isolation contract as the async path per spec §6). + only for ``"started"``-phase events, with errors warned-not- + propagated (same isolation contract as the async path). """ async def __call__(self, event: NodeEvent, /) -> None: ... @@ -103,25 +104,24 @@ async def __call__(self, event: NodeEvent, /) -> None: ... class SubscribedObserver: """An observer paired with its phase subscription set. - Per spec v0.6.0 §6: observers register with an optional `phases` - parameter naming the phase strings they want to receive. The - default is `ALL_PHASES` — historically named when there were only - two phases, now meaning "the default subscription" (`{"started", - "completed"}`). The new `"checkpoint_saved"` phase from - pipeline-utilities §10.8 is opt-in: subscribe to it explicitly via - `phases={"checkpoint_saved"}` (or include it in a custom set). - `KNOWN_PHASES` is the full "every phase the engine can produce" + Observers register with an optional ``phases`` parameter naming + the phase strings they want to receive. The default is + ``ALL_PHASES`` — historically named when there were only two + phases, now meaning "the default subscription" + (``{"started", "completed"}``). The ``"checkpoint_saved"`` phase + is opt-in: subscribe to it explicitly via + ``phases={"checkpoint_saved"}`` (or include it in a custom set). + ``KNOWN_PHASES`` is the full "every phase the engine can produce" set used by the registration-time validator. - Empty phase sets are forbidden — passing one raises `ValueError` - at registration time per the spec's "implementations SHOULD raise" - guidance, hardened to MUST here so misconfiguration surfaces + Empty phase sets are forbidden — passing one raises + ``ValueError`` at registration time so misconfiguration surfaces immediately. - Construct one of these directly when handing phase-filtered observers - to `CompiledGraph.invoke(observers=...)`. For the single-observer - `attach_observer` path, pass `phases=` as a keyword argument and the - engine wraps it for you. + Construct one of these directly when handing phase-filtered + observers to ``CompiledGraph.invoke(observers=...)``. For the + single-observer ``attach_observer`` path, pass ``phases=`` as a + keyword argument and the engine wraps it for you. """ observer: Observer @@ -129,7 +129,7 @@ class SubscribedObserver: def __post_init__(self) -> None: if not self.phases: - raise ValueError("phases must be non-empty; spec §6 forbids empty phase subscriptions") + raise ValueError("phases must be non-empty") invalid = self.phases - KNOWN_PHASES if invalid: raise ValueError(f"unknown phase(s): {sorted(invalid)}; allowed: {sorted(KNOWN_PHASES)}") @@ -161,12 +161,12 @@ def _coerce_subscribed( @dataclass(frozen=True) class RemoveHandle: - """Returned by `CompiledGraph.attach_observer`. Call `.remove()` to - detach the observer. Idempotent — calling `.remove()` after the - observer is already detached is a no-op. + """Returned by ``CompiledGraph.attach_observer``. Call + ``.remove()`` to detach the observer. Idempotent — calling + ``.remove()`` after the observer is already detached is a no-op. - Per spec v0.6.0 §6: changes to the registered observer set during a - graph run do NOT take effect until the next invocation. + Changes to the registered observer set during a graph run do NOT + take effect until the next invocation. """ _observers: list[SubscribedObserver] @@ -379,9 +379,9 @@ def _dispatch(context: _InvocationContext, event: NodeEvent) -> None: prep — the wrapper acts as a uniform phase shield across both sync prep and async dispatch. - Errors from ``prepare_sync`` follow the same isolation contract as - the async path per spec §6: don't propagate, don't break siblings, - don't block the queueing or subsequent events. Reported via + Errors from ``prepare_sync`` follow the same isolation contract + as the async path: don't propagate, don't break siblings, don't + block the queueing or subsequent events. Reported via ``warnings.warn``. No-op when no observers exist for this depth — avoids paying the queue @@ -449,17 +449,17 @@ def _dispatch(context: _InvocationContext, event: NodeEvent) -> None: async def deliver_loop(queue: asyncio.Queue[_QueuedItem | None]) -> None: """Background worker: read queued events, deliver to observers serially. - Per spec v0.6.0 §6: - - No two observers receive the same event concurrently (we await each). - - No observer receives event N+1 until everyone has finished N (the - loop processes one item fully before pulling the next). - - Observers whose `phases` set excludes the event's phase do NOT - receive it. Phase filter applies at delivery, not dispatch — the - engine still produces both events for every attempt. - - Observer exceptions don't propagate, don't break siblings, don't - block subsequent events. Reported via `warnings.warn`. - - The loop terminates when it receives `_DRAIN_SENTINEL` (None). + - No two observers receive the same event concurrently (we await + each). + - No observer receives event N+1 until everyone has finished N + (the loop processes one item fully before pulling the next). + - Observers whose ``phases`` set excludes the event's phase do + NOT receive it. Phase filter applies at delivery, not dispatch + — the engine still produces both events for every attempt. + - Observer exceptions don't propagate, don't break siblings, + don't block subsequent events. Reported via ``warnings.warn``. + + The loop terminates when it receives ``_DRAIN_SENTINEL`` (None). """ while True: item = await queue.get() diff --git a/src/openarmature/graph/projection.py b/src/openarmature/graph/projection.py index 54532850..4ce2879e 100644 --- a/src/openarmature/graph/projection.py +++ b/src/openarmature/graph/projection.py @@ -1,13 +1,13 @@ """Subgraph projection strategies. -Per spec v0.2.0 §2 Subgraph: the default is **no projection in** (a subgraph -runs from its own schema's field defaults) and **field-name matching for -projection out** (subgraph fields whose names match parent fields are merged +The default is **no projection in** (a subgraph runs from its own +schema's field defaults) and **field-name matching for projection +out** (subgraph fields whose names match parent fields are merged back into the parent via the parent's reducers). -Spec v0.2.0 (proposal 0002) adds explicit input/output mapping: a -subgraph-as-node MAY declare `inputs` (parent → subgraph, additive over the -default of no-projection-in) and/or `outputs` (subgraph → parent, replacement +A subgraph-as-node MAY also declare ``inputs`` (parent → subgraph, +additive over the default of no-projection-in) and/or ``outputs`` +(subgraph → parent, replacement for field-name matching). Implemented here as `ExplicitMapping`. Strategies parameterize on parent and child state types so consumer-authored @@ -27,13 +27,13 @@ def _field_name_match_projection[ChildT: State]( parent_state: State, subgraph_state_cls: type[ChildT], ) -> Mapping[str, Any]: - """Spec v0.2 §2 default projection-out: subgraph fields whose names - match parent fields are merged back via the parent's reducers; non- - matching subgraph fields are discarded. + """Default projection-out: subgraph fields whose names match + parent fields are merged back via the parent's reducers; + non-matching subgraph fields are discarded. - Shared by `FieldNameMatching.project_out` (which always uses it) and - `ExplicitMapping.project_out` (which falls back to it when `outputs` - was not declared, per spec v0.2). + Shared by ``FieldNameMatching.project_out`` (which always uses it) + and ``ExplicitMapping.project_out`` (which falls back to it when + ``outputs`` was not declared). """ parent_fields = set(type(parent_state).model_fields.keys()) sub_fields = set(subgraph_state_cls.model_fields.keys()) @@ -74,13 +74,13 @@ def project_out( class FieldNameMatching[ParentT: State, ChildT: State]: - """Default projection per spec v0.2.0 §2 Subgraph. + """Default subgraph projection strategy. - Parameterized for protocol conformance under generics. `ParentT` is not - consumed (the default projection ignores parent state on the way in), - but carrying the type variable keeps the default assignable to - `ProjectionStrategy[ParentT, ChildT]` without type gymnastics at the - SubgraphNode default-factory site. + Parameterized for protocol conformance under generics. ``ParentT`` + is not consumed (the default projection ignores parent state on + the way in), but carrying the type variable keeps the default + assignable to ``ProjectionStrategy[ParentT, ChildT]`` without type + gymnastics at the SubgraphNode default-factory site. """ def project_in(self, parent_state: ParentT, subgraph_state_cls: type[ChildT]) -> ChildT: @@ -96,25 +96,27 @@ def project_out( class ExplicitMapping[ParentT: State, ChildT: State]: - """Per spec v0.2.0 §2: explicit input/output mapping. - - `inputs`: subgraph_field → parent_field. At entry, the named parent field's - current value is copied into the named subgraph field. Subgraph fields not - listed receive their schema-declared defaults — there is NO field-name - fallback (additive over the spec's default no-projection-in). - - `outputs`: parent_field → subgraph_field. At exit, the named subgraph - field's value is merged into the named parent field via the parent's - reducer. Subgraph fields not listed are discarded — `outputs` REPLACES - field-name matching for projection-out. - - The two directions are independent: pass either, both, or neither. The - spec distinguishes "absent" (default applies) from "present but empty" - (only for `outputs`, where the defaults differ); `outputs=None` means - absent (fall back to field-name matching), `outputs={}` means present - and empty (project nothing). For `inputs` the two defaults coincide - (no-projection-in either way), so the distinction is only meaningful - for `outputs`. + """Explicit input/output mapping between parent and subgraph + state. + + ``inputs``: subgraph_field → parent_field. At entry, the named + parent field's current value is copied into the named subgraph + field. Subgraph fields not listed receive their schema-declared + defaults — there is NO field-name fallback (additive over the + default no-projection-in). + + ``outputs``: parent_field → subgraph_field. At exit, the named + subgraph field's value is merged into the named parent field via + the parent's reducer. Subgraph fields not listed are discarded — + ``outputs`` REPLACES field-name matching for projection-out. + + The two directions are independent: pass either, both, or + neither. The ``outputs`` field distinguishes "absent" (default + applies) from "present but empty"; ``outputs=None`` means absent + (fall back to field-name matching), ``outputs={}`` means present + and empty (project nothing). For ``inputs`` the two defaults + coincide (no-projection-in either way), so the distinction is + only meaningful for ``outputs``. """ def __init__( diff --git a/src/openarmature/graph/reducers.py b/src/openarmature/graph/reducers.py index 60ea3e3f..80f8550c 100644 --- a/src/openarmature/graph/reducers.py +++ b/src/openarmature/graph/reducers.py @@ -1,8 +1,12 @@ +# Spec: realizes graph-engine §2 (Reducer concept) — last_write_wins, +# append, and merge are the three built-ins the spec requires. + """Reducers for merging node updates into state. -Per spec §2 Concepts (Reducer): each state field has exactly one reducer; the -default is last_write_wins. Implementations MUST provide last_write_wins, -append (for list-typed fields), and merge (for mapping-typed fields). +Each state field has exactly one reducer; the default is +``last_write_wins``. The three built-ins are ``last_write_wins``, +``append`` (for list-typed fields), and ``merge`` (for mapping-typed +fields). """ from collections.abc import Mapping diff --git a/src/openarmature/graph/state.py b/src/openarmature/graph/state.py index 42039710..746dd368 100644 --- a/src/openarmature/graph/state.py +++ b/src/openarmature/graph/state.py @@ -1,8 +1,12 @@ +# Spec: state is the typed product type from graph-engine §2 Concepts, +# validated at graph boundaries. Immutability (Pydantic frozen) enforces +# that nodes cannot mutate the object they receive; ``extra="forbid"`` +# enforces "typed product type validated at graph boundaries." + """Typed state schemas. -Per spec §2 Concepts (State): state is a typed product type validated at graph -boundaries. State is immutable (Pydantic frozen) so nodes cannot mutate the -object they receive. +State is a typed, immutable product type. Nodes receive a snapshot +and return partial updates; the engine merges via per-field reducers. Per-field reducers are declared via Annotated metadata:: @@ -21,9 +25,10 @@ class S(State): class State(BaseModel): """Base for graph state schemas. Immutable; reducers attach via Annotated.""" - # `extra="forbid"` makes node updates that name an undeclared field surface - # as a `state_validation_error` at the merge step, matching spec §2's - # "typed product type validated at graph boundaries" intent. + # ``extra="forbid"`` makes node updates that name an undeclared + # field surface as a ``state_validation_error`` at the merge step, + # matching spec §2's "typed product type validated at graph + # boundaries" intent. model_config = ConfigDict(frozen=True, extra="forbid") diff --git a/src/openarmature/graph/subgraph.py b/src/openarmature/graph/subgraph.py index f29bc044..c44eb217 100644 --- a/src/openarmature/graph/subgraph.py +++ b/src/openarmature/graph/subgraph.py @@ -1,13 +1,13 @@ """Subgraphs as nodes. -Per spec v0.2.0 §2 Subgraph: a compiled graph is used as a node inside another -graph. The subgraph runs against its own state schema; projection between -parent and subgraph is delegated to a `ProjectionStrategy` (default: -`FieldNameMatching`; spec v0.2.0 also defines `ExplicitMapping`). +A compiled graph is used as a node inside another graph. The +subgraph runs against its own state schema; projection between parent +and subgraph is delegated to a ``ProjectionStrategy`` (default: +``FieldNameMatching``; ``ExplicitMapping`` is also available). -Per spec v0.3.0 §6 Observer hooks: when a subgraph runs as part of a parent -invocation, its inner-node events bubble up to outer observers (in addition -to the subgraph's own attached observers), the step counter spans the +When a subgraph runs as part of a parent invocation, its inner-node +events bubble up to outer observers (in addition to the subgraph's +own attached observers), the step counter spans the subgraph boundary, and the namespace extends. SubgraphNode.run accepts an optional `_InvocationContext` so the engine can thread that context through; called without it (e.g., direct test invocation), SubgraphNode falls back to @@ -38,10 +38,10 @@ class SubgraphNode[ParentT: State, ChildT: State]: """A node backed by a compiled subgraph. - Per pipeline-utilities §4: the parent's per-node middleware on a - SubgraphNode wraps the subgraph dispatch as a single atomic call — - parent middleware does NOT cross into the subgraph's internal nodes - (those are wrapped by the subgraph's own middleware independently). + The parent's per-node middleware on a SubgraphNode wraps the + subgraph dispatch as a single atomic call — parent middleware + does NOT cross into the subgraph's internal nodes (those are + wrapped by the subgraph's own middleware independently). """ name: str @@ -63,11 +63,11 @@ async def run( public `invoke()` — a fresh root invocation with no parent observer chain. - When `context` is provided (the engine's normal path during a parent - run), the subgraph descends into a child context that shares the - parent's queue + step counter and extends the namespace and parent- - state stack. Observer events from inner nodes bubble up to outer - observers per spec v0.3.0 §6. + When `context` is provided (the engine's normal path during + a parent run), the subgraph descends into a child context + that shares the parent's queue + step counter and extends the + namespace and parent-state stack. Observer events from inner + nodes bubble up to outer observers. """ # Resume-with-saved-inner-state (spec pipeline-utilities §10.4): # if the loaded record's latest save fired from inside this diff --git a/src/openarmature/llm/__init__.py b/src/openarmature/llm/__init__.py index 0408bcb2..b2585bb0 100644 --- a/src/openarmature/llm/__init__.py +++ b/src/openarmature/llm/__init__.py @@ -1,4 +1,7 @@ -"""openarmature.llm — llm-provider capability per spec proposal 0006. +# Spec: this package implements the llm-provider capability (spec +# proposal 0006). + +"""openarmature.llm — LLM provider abstraction. Public surface: typed ``Message`` / ``Tool`` / ``Response``, the ``Provider`` Protocol, the canonical error categories, and an @@ -14,7 +17,7 @@ UserMessage, ) -All seven §7 error categories and the canonical ``TRANSIENT_CATEGORIES`` +All seven error categories and the canonical ``TRANSIENT_CATEGORIES`` frozenset are also re-exported here so callers writing custom retry classifiers don't have to reach into ``openarmature.llm.errors``. """ @@ -47,7 +50,7 @@ UserMessage, ) from .provider import Provider, validate_message_list, validate_tools -from .providers import OpenAIProvider +from .providers import OpenAIProvider, classify_http_error, parse_retry_after from .response import FinishReason, Response, RuntimeConfig, Usage __all__ = [ @@ -80,6 +83,8 @@ "ToolMessage", "Usage", "UserMessage", + "classify_http_error", + "parse_retry_after", "validate_message_list", "validate_tools", ] diff --git a/src/openarmature/llm/errors.py b/src/openarmature/llm/errors.py index 70107546..a3c75f93 100644 --- a/src/openarmature/llm/errors.py +++ b/src/openarmature/llm/errors.py @@ -1,17 +1,17 @@ +# Spec: realizes llm-provider §7 (seven canonical error categories). + """Errors raised by an llm-provider implementation. -Per spec llm-provider §7: a provider call (``ready()`` or -``complete()``) MAY raise one of seven canonical category errors. -Each error class carries a ``category`` class attribute matching the -canonical string identifier so callers can dispatch on the category -without matching exception types directly. +A provider call (``ready()`` or ``complete()``) MAY raise one of +seven canonical category errors. Each error class carries a +``category`` class attribute matching the canonical string identifier +so callers can dispatch on the category without matching exception +types directly. This module is also the single source of truth for the canonical category strings — :data:`TRANSIENT_CATEGORIES` lives here, and ``openarmature.graph.middleware.retry``'s default classifier imports -it. Phase 2's retry middleware deliberately hardcoded the set to -avoid a circular dependency before llm-provider was implemented; -now that this module exists, the strings have a real home. +it. """ from __future__ import annotations @@ -19,7 +19,7 @@ from typing import Any # --------------------------------------------------------------------------- -# Canonical category strings (spec §7) +# Canonical category strings (llm-provider spec §7) # --------------------------------------------------------------------------- PROVIDER_AUTHENTICATION = "provider_authentication" @@ -57,7 +57,8 @@ class LlmProviderError(Exception): """Base for all llm-provider errors. Each subclass carries a - ``category`` class attribute matching one of the spec §7 strings. + ``category`` class attribute matching one of the canonical + category strings above. Provider-originated errors SHOULD preserve the underlying provider exception as ``__cause__`` so callers can reach the wire-level @@ -114,8 +115,8 @@ def __init__(self, *args: Any, retry_after: float | None = None) -> None: class ProviderInvalidResponse(LlmProviderError): """Provider returned a malformed response that cannot be parsed - into the §6 shape (missing required fields, invalid tool_calls - structure, invalid JSON).""" + into the expected :class:`Response` shape (missing required + fields, invalid tool_calls structure, invalid JSON).""" category = PROVIDER_INVALID_RESPONSE diff --git a/src/openarmature/llm/messages.py b/src/openarmature/llm/messages.py index 05223cf6..fab9de70 100644 --- a/src/openarmature/llm/messages.py +++ b/src/openarmature/llm/messages.py @@ -1,4 +1,8 @@ -"""Message, Tool, ToolCall — the typed conversation surface (spec §3 + §4). +# Spec: realizes llm-provider §3 (Message + ToolCall typed surface + +# validation timing) and §4 (Tool definition). Tool-call ids preserved +# verbatim — no rewrite or normalization, per spec §3. + +"""Message, Tool, ToolCall — the typed conversation surface. A conversation is an ordered list of messages, one of four kinds discriminated by ``role``: ``system``, ``user``, ``assistant``, @@ -11,13 +15,13 @@ ``tool_call_id`` matches an earlier assistant ``ToolCall.id``" — are checked at the ``complete()`` boundary, not at construction (a single Message can't see the rest of the list). Both layers are -required: spec §3 "Validation timing". +required. -Tool-call ids are preserved verbatim per spec §3 — implementations -MUST NOT rewrite or normalize provider-supplied ids. The ``id`` -field is a plain ``str`` with no normalizer, so a UUID with hyphens, -a vendor-prefixed id (``bifrost_abc-def``), or any other string -shape round-trips unchanged. +Tool-call ids are preserved verbatim — implementations MUST NOT +rewrite or normalize provider-supplied ids. The ``id`` field is a +plain ``str`` with no normalizer, so a UUID with hyphens, a +vendor-prefixed id (``bifrost_abc-def``), or any other string shape +round-trips unchanged. """ from __future__ import annotations @@ -30,9 +34,9 @@ class ToolCall(BaseModel): """An assistant's request to invoke a named tool. - Per spec §3: ``id`` is opaque correlator within a single message - list. Implementations MUST preserve provider-supplied ids - verbatim — neither rewriting nor normalizing. + ``id`` is an opaque correlator within a single message list. + Implementations MUST preserve provider-supplied ids verbatim — + neither rewriting nor normalizing. """ model_config = ConfigDict(extra="forbid") @@ -49,13 +53,12 @@ class ToolCall(BaseModel): class Tool(BaseModel): """A function the model may request the user execute. - Per spec §4: ``parameters`` is a JSON Schema (object schema) - describing the argument record. Kept as a plain ``dict[str, Any]`` - rather than a typed schema class so the spec's - "JSON Schema, not language-native types" intent surfaces directly - — implementations may offer ergonomic constructors that compile - from native types (Pydantic ``model_json_schema()``) but the - surface is JSON Schema. + ``parameters`` is a JSON Schema (object schema) describing the + argument record. Kept as a plain ``dict[str, Any]`` rather than a + typed schema class so the "JSON Schema, not language-native + types" intent surfaces directly — implementations may offer + ergonomic constructors that compile from native types (Pydantic + ``model_json_schema()``) but the surface is JSON Schema. """ model_config = ConfigDict(extra="forbid") @@ -78,8 +81,8 @@ class _MessageBase(BaseModel): class SystemMessage(_MessageBase): - """Spec §3: system messages have non-empty ``content``; no - tool_calls; no tool_call_id.""" + """System messages have non-empty ``content``; no tool_calls; no + tool_call_id.""" role: Literal["system"] = "system" content: str @@ -92,8 +95,8 @@ def _check_content(self) -> SystemMessage: class UserMessage(_MessageBase): - """Spec §3: user messages have non-empty ``content``; no - tool_calls; no tool_call_id.""" + """User messages have non-empty ``content``; no tool_calls; no + tool_call_id.""" role: Literal["user"] = "user" content: str @@ -106,10 +109,10 @@ def _check_content(self) -> UserMessage: class AssistantMessage(_MessageBase): - """Spec §3: assistant messages MAY carry ``tool_calls``. If - ``tool_calls`` is present and non-empty, ``content`` MAY be empty - (the assistant is purely calling tools); otherwise ``content`` - MUST be a non-empty string. ``tool_call_id`` MUST be absent.""" + """Assistant messages MAY carry ``tool_calls``. If ``tool_calls`` + is present and non-empty, ``content`` MAY be empty (the assistant + is purely calling tools); otherwise ``content`` MUST be a + non-empty string. ``tool_call_id`` MUST be absent.""" role: Literal["assistant"] = "assistant" content: str = "" @@ -126,10 +129,10 @@ def _check_content_or_tools(self) -> AssistantMessage: class ToolMessage(_MessageBase): - """Spec §3: tool messages carry the textual result of a tool call. + """Tool messages carry the textual result of a tool call. ``tool_call_id`` MUST be present and match the ``id`` of an - earlier assistant ToolCall in the same message list. The list- - level matching is checked at the ``complete()`` boundary by + earlier assistant ToolCall in the same message list. The + list-level matching is checked at the ``complete()`` boundary by :func:`provider.validate_message_list`, not at construction.""" role: Literal["tool"] = "tool" diff --git a/src/openarmature/llm/provider.py b/src/openarmature/llm/provider.py index 470b51c2..60d6f32f 100644 --- a/src/openarmature/llm/provider.py +++ b/src/openarmature/llm/provider.py @@ -1,24 +1,27 @@ -"""Provider Protocol + list-level message validation (spec §3, §5). +# Spec: realizes llm-provider §3 (Message + validation timing), +# §5 (Provider Protocol operations), §7 (canonical error categories). + +"""Provider Protocol + list-level message validation. A ``Provider`` is stateless — every call carries the full message list. It does not loop on tool calls (the caller is responsible for executing tools and making a follow-on ``complete()`` with results) and it does not retry on transient errors (that's middleware's job). -Per spec §5 a provider MUST expose two operations: +A provider MUST expose two operations: - ``async ready() -> None`` — verifies the bound model is reachable. A successful return implies the next ``complete()`` would not - raise §7 categories that surface mismatched configuration or - unloaded state. + raise errors that surface mismatched configuration or unloaded + state. - ``async complete(messages, tools=None, config=None) -> Response`` — performs a single completion. Stateless, reentrant, MUST NOT mutate its inputs. -This module also exports :func:`validate_message_list`: a list- -level invariant check (per spec §3 "Validation timing") that -complements per-message Pydantic validation. A single ``Message`` -can't see the rest of the list, so the boundary check enforces: +This module also exports :func:`validate_message_list`: a list-level +invariant check that complements per-message Pydantic validation. A +single ``Message`` can't see the rest of the list, so the boundary +check enforces: - The list is non-empty. - The first message MAY be ``system``; otherwise the list begins @@ -27,7 +30,7 @@ - Every ``tool`` message's ``tool_call_id`` matches the ``id`` of an earlier assistant ``ToolCall``. -Violations raise ``provider_invalid_request`` per §7. +Violations raise ``provider_invalid_request``. """ from __future__ import annotations @@ -78,7 +81,7 @@ async def complete( def validate_message_list(messages: Sequence[Message]) -> None: - """Validate list-level invariants per spec §3 + §5. + """Validate list-level invariants. Per-message constraints (system/user need non-empty content, assistant content-or-tool_calls, etc.) are enforced by Pydantic @@ -141,8 +144,8 @@ def validate_message_list(messages: Sequence[Message]) -> None: def validate_tools(tools: Sequence[Tool] | None) -> None: - """Validate spec §4 tool-list invariants. Tool names MUST be - unique within a single ``complete()`` call.""" + """Validate tool-list invariants. Tool names MUST be unique + within a single ``complete()`` call.""" if not tools: return seen: set[str] = set() diff --git a/src/openarmature/llm/providers/__init__.py b/src/openarmature/llm/providers/__init__.py index 91a1ae24..ea882164 100644 --- a/src/openarmature/llm/providers/__init__.py +++ b/src/openarmature/llm/providers/__init__.py @@ -14,6 +14,10 @@ mirror the ``openarmature.graph.middleware`` layout. """ -from .openai import OpenAIProvider +from .openai import OpenAIProvider, classify_http_error, parse_retry_after -__all__ = ["OpenAIProvider"] +__all__ = [ + "OpenAIProvider", + "classify_http_error", + "parse_retry_after", +] diff --git a/src/openarmature/llm/providers/openai.py b/src/openarmature/llm/providers/openai.py index a4bcd498..f720990d 100644 --- a/src/openarmature/llm/providers/openai.py +++ b/src/openarmature/llm/providers/openai.py @@ -1,14 +1,17 @@ -"""OpenAI-compatible HTTPX-based provider (spec §8). +# Spec: realizes llm-provider §8 (concrete OpenAI provider) including +# the §8.3 wire-error mapping table. -Implements the spec's :class:`Provider` Protocol against the OpenAI -Chat Completions wire format (``POST /v1/chat/completions``). The -same wire format is the de facto standard for vLLM, LM Studio, -llama.cpp, and other local LLM servers, so this provider talks to -all of them with the right ``base_url``. +"""OpenAI-compatible HTTPX-based provider. -**Error mapping (spec §8.3):** +Implements the :class:`Provider` Protocol against the OpenAI Chat +Completions wire format (``POST /v1/chat/completions``). The same +wire format is the de facto standard for vLLM, LM Studio, llama.cpp, +and other local LLM servers, so this provider talks to all of them +with the right ``base_url``. -| OpenAI condition | Spec category | +**Error mapping:** + +| OpenAI condition | Category | |---------------------------------------------------|------------------------------| | ``ConnectError``/``ConnectTimeout``/``ReadTimeout``/network | provider_unavailable | | HTTP 401, 403 | provider_authentication | @@ -17,7 +20,7 @@ | HTTP 429 (with ``Retry-After`` → ``retry_after``) | provider_rate_limit | | HTTP 400 (schema violation) | provider_invalid_request | | HTTP 5xx (other) | provider_unavailable | -| 200 OK that fails to parse into §6 shape | provider_invalid_response | +| 200 OK that fails to parse into Response shape | provider_invalid_response | **``ready()`` probe.** Hits ``GET /v1/models`` and: @@ -26,13 +29,13 @@ - 200 + bound model in returned list → success. - 200 + bound model NOT in list → ``provider_invalid_model``. -The spec's ``provider_model_not_loaded`` distinction needs a -server-specific probe (LM Studio's loaded-vs-configured endpoint, -vLLM's health endpoint, llama.cpp's runtime-status endpoint) that -this base provider can't generically emit. Subclasses or -purpose-built local-server provider variants close that gap; the -base ``OpenAIProvider`` documents the limitation here rather than -silently treating "model in catalog" as "model loaded." +The ``provider_model_not_loaded`` distinction needs a server-specific +probe (LM Studio's loaded-vs-configured endpoint, vLLM's health +endpoint, llama.cpp's runtime-status endpoint) that this base +provider can't generically emit. Subclasses or purpose-built +local-server provider variants close that gap; the base +``OpenAIProvider`` documents the limitation here rather than silently +treating "model in catalog" as "model loaded." """ from __future__ import annotations @@ -55,6 +58,7 @@ ) from ..errors import ( + LlmProviderError, ProviderAuthentication, ProviderInvalidModel, ProviderInvalidRequest, @@ -188,13 +192,13 @@ async def complete( tools: Sequence[Tool] | None = None, config: RuntimeConfig | None = None, ) -> Response: - """Single completion call per spec §5. + """Single completion call. - Pre-send validation runs first (per-message Pydantic + list- - level invariants per §3 "Validation timing"). HTTP errors - map to §7 categories per §8.3. The successful 200 body is - parsed into a :class:`Response` per §8.2 — failure to parse - raises ``provider_invalid_response``. + Pre-send validation runs first (per-message Pydantic + + list-level invariants). HTTP errors map to canonical + provider-error categories. The successful 200 body is parsed + into a :class:`Response` — failure to parse raises + ``provider_invalid_response``. """ validate_message_list(messages) validate_tools(tools) @@ -250,7 +254,7 @@ async def _do_complete(self, body: dict[str, Any]) -> Response: raise ProviderUnavailable(str(exc)) from exc if resp.status_code != 200: - raise self._classify_http_error(resp) + raise classify_http_error(resp) try: payload_raw = resp.json() @@ -361,51 +365,6 @@ def _parse_response(self, payload: dict[str, Any]) -> Response: raw=payload, ) - # ------------------------------------------------------------------ - # Error classification (spec §8.3) - # ------------------------------------------------------------------ - - def _classify_http_error(self, resp: httpx.Response) -> Exception: - """Map a non-200 ``httpx.Response`` to the right §7 category. - Returns the exception (not raises) so the caller can ``raise`` - with consistent traceback context.""" - status = resp.status_code - try: - body_raw = resp.json() - except ValueError: - body_raw = {} - body: dict[str, Any] = cast("dict[str, Any]", body_raw) if isinstance(body_raw, dict) else {} - error_block_raw = body.get("error") - error_block: dict[str, Any] = ( - cast("dict[str, Any]", error_block_raw) if isinstance(error_block_raw, dict) else {} - ) - error_type = error_block.get("type") - error_code = error_block.get("code") - message_raw = error_block.get("message") - message = message_raw if isinstance(message_raw, str) else None - - if status in (401, 403): - return ProviderAuthentication(message or f"HTTP {status}") - if status == 400: - return ProviderInvalidRequest(message or "HTTP 400") - if status == 404: - # 404 with model-not-found body → invalid_model. - if error_code == "model_not_found" or _looks_like_model_not_found(error_type): - return ProviderInvalidModel(message or "model not found") - return ProviderUnavailable(message or "HTTP 404") - if status == 429: - retry_after = _parse_retry_after(resp.headers.get("Retry-After")) - return ProviderRateLimit(message or "HTTP 429", retry_after=retry_after) - if status == 503: - # 503 with model-loading body → not_loaded; otherwise - # generic unavailability. - if error_type == "model_not_loaded" or _looks_like_model_not_loaded(message): - return ProviderModelNotLoaded(message or "model not loaded") - return ProviderUnavailable(message or "HTTP 503") - if 500 <= status < 600: - return ProviderUnavailable(message or f"HTTP {status}") - return ProviderUnavailable(message or f"HTTP {status}") - # --------------------------------------------------------------------------- # Wire-format helpers @@ -505,11 +464,66 @@ def _wire_to_assistant_message(wire: dict[str, Any], *, lenient_args: bool) -> A ) -def _parse_retry_after(value: str | None) -> float | None: +def classify_http_error(resp: httpx.Response) -> LlmProviderError: + """Map a non-200 ``httpx.Response`` from an OpenAI-shape API to + the right canonical error category. + + Returns the exception (does not raise) so the caller can + ``raise`` with consistent traceback context. + + Reusable by third-party Provider implementations targeting any + OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp server, + etc.) — the wire shape is stable across these and the helper + saves implementers from reimplementing the mapping table. + """ + status = resp.status_code + try: + body_raw = resp.json() + except ValueError: + body_raw = {} + body: dict[str, Any] = cast("dict[str, Any]", body_raw) if isinstance(body_raw, dict) else {} + error_block_raw = body.get("error") + error_block: dict[str, Any] = ( + cast("dict[str, Any]", error_block_raw) if isinstance(error_block_raw, dict) else {} + ) + error_type = error_block.get("type") + error_code = error_block.get("code") + message_raw = error_block.get("message") + message = message_raw if isinstance(message_raw, str) else None + + if status in (401, 403): + return ProviderAuthentication(message or f"HTTP {status}") + if status == 400: + return ProviderInvalidRequest(message or "HTTP 400") + if status == 404: + # 404 with model-not-found body → invalid_model. + if error_code == "model_not_found" or _looks_like_model_not_found(error_type): + return ProviderInvalidModel(message or "model not found") + return ProviderUnavailable(message or "HTTP 404") + if status == 429: + retry_after = parse_retry_after(resp.headers.get("Retry-After")) + return ProviderRateLimit(message or "HTTP 429", retry_after=retry_after) + if status == 503: + # 503 with model-loading body → not_loaded; otherwise + # generic unavailability. + if error_type == "model_not_loaded" or _looks_like_model_not_loaded(message): + return ProviderModelNotLoaded(message or "model not loaded") + return ProviderUnavailable(message or "HTTP 503") + if 500 <= status < 600: + return ProviderUnavailable(message or f"HTTP {status}") + return ProviderUnavailable(message or f"HTTP {status}") + + +def parse_retry_after(value: str | None) -> float | None: """Parse a ``Retry-After`` header value to a float seconds count. + HTTP allows seconds-int OR HTTP-date; this implementation handles the seconds-int form (the OpenAI/vendor norm) and ignores - HTTP-date.""" + HTTP-date. + + Reusable by third-party Provider implementations that need to + surface ``Retry-After`` to ``ProviderRateLimit.retry_after``. + """ if value is None: return None try: @@ -651,4 +665,6 @@ def _make_llm_event( __all__ = [ "OpenAIProvider", + "classify_http_error", + "parse_retry_after", ] diff --git a/src/openarmature/llm/response.py b/src/openarmature/llm/response.py index 1d118d6b..8626ecf5 100644 --- a/src/openarmature/llm/response.py +++ b/src/openarmature/llm/response.py @@ -1,17 +1,22 @@ -"""Response and RuntimeConfig (spec §6). +# Spec: realizes llm-provider §6 (Response shape + RuntimeConfig). +# ``raw`` follows charter §3.1 principle 8 (Transparency over +# abstraction) — carries everything the provider returned, including +# fields the spec doesn't normalize (logprobs, content-filter detail, +# vendor extensions). + +"""Response and RuntimeConfig. The ``Response`` is what ``Provider.complete()`` returns: the assistant message, a finish reason, optional usage, and the verbatim -parsed provider response. Per charter §3.1 principle 8 -"Transparency over abstraction", ``raw`` carries everything the -provider returned — including fields the spec doesn't normalize +parsed provider response. ``raw`` carries everything the provider +returned — including fields the abstraction doesn't normalize (logprobs, content-filter detail, vendor-specific extensions) so users who need them can reach through the abstraction directly. ``RuntimeConfig`` is the optional per-call sampling-parameter record. Implementations MAY accept additional provider-specific fields; the four declared here (temperature, max_tokens, top_p, seed) are the -spec-mandated minimum. +mandated minimum. """ from __future__ import annotations @@ -30,7 +35,7 @@ class Usage(BaseModel): - """Token-accounting record per spec §6. + """Token-accounting record. Each field is a non-negative integer or ``None``. If the provider does not report usage, all three MUST be ``None``. @@ -46,11 +51,11 @@ class Usage(BaseModel): class Response(BaseModel): """The result of a ``Provider.complete()`` call. - Per spec §6: - - ``message`` is the assistant message returned by the model. Always ``role: "assistant"``. May carry ``tool_calls``. - - ``finish_reason`` is one of the five spec values. + - ``finish_reason`` is one of the five canonical values + (``"stop"`` / ``"length"`` / ``"tool_calls"`` / + ``"content_filter"`` / ``"error"``). - ``usage`` is the token record (all ``None`` if the provider didn't report usage). - ``raw`` is the parsed provider response, populated on every @@ -67,11 +72,10 @@ class Response(BaseModel): class RuntimeConfig(BaseModel): - """Per-call sampling parameters and budget hints (spec §6). + """Per-call sampling parameters and budget hints. All four fields are optional. Implementations MAY accept - additional provider-specific fields; this is the spec-mandated - minimum. + additional provider-specific fields; this is the minimum. """ model_config = ConfigDict(extra="allow") diff --git a/src/openarmature/observability/__init__.py b/src/openarmature/observability/__init__.py index 6a53ca45..44a0225f 100644 --- a/src/openarmature/observability/__init__.py +++ b/src/openarmature/observability/__init__.py @@ -1,22 +1,25 @@ +# Spec: this package implements the observability capability — +# core ContextVar primitives from observability §3; backend mappings +# (OTel here) realize §4-§7. Split mirrors charter §3.1 principle 5 +# (core defines contracts; specific backends implement them). + """openarmature.observability — cross-backend observability surface. Two layers: - **Core** (this module + ``correlation.py``): always available, no extra dependencies. Exposes :func:`current_correlation_id` and - :func:`current_active_observers` — the spec observability §3 - ``ContextVar`` primitives that every backend mapping consumes. + :func:`current_active_observers` — the ``ContextVar`` primitives + that every backend mapping consumes. - **Backend mappings** (under ``observability.otel`` and future ``observability.langfuse`` etc.): gated behind optional dependencies (``pip install openarmature[otel]``). Importing the subpackage without the extras installed raises an informative ``ImportError`` pointing the caller at the install command. -The split mirrors charter §3.1 principle 5: core defines the -contracts; specific backends implement them. At v1.0 launch the -backend mappings will lift into sibling packages -(``openarmature-otel``, ``openarmature-langfuse``) — until then -they live here under per-backend subpackages so the layering is +At v1.0 launch the backend mappings will lift into sibling packages +(``openarmature-otel``, ``openarmature-langfuse``) — until then they +live here under per-backend subpackages so the layering is established up front. """ diff --git a/src/openarmature/observability/correlation.py b/src/openarmature/observability/correlation.py index d8a714b3..3b358810 100644 --- a/src/openarmature/observability/correlation.py +++ b/src/openarmature/observability/correlation.py @@ -1,28 +1,31 @@ -"""Cross-backend correlation primitives (spec observability §3). +# Spec: realizes observability §3 (correlation primitives). +# ``current_correlation_id`` and ``_active_observers`` are +# ContextVar-backed per §3.1's "MUST propagate via the language's +# idiomatic context primitive" requirement. + +"""Cross-backend correlation primitives. Two ``ContextVar``-backed primitives that any observability backend -mapping (OTel here, Langfuse / Datadog / custom in the future) consumes -through a uniform user-readable surface: +mapping (OTel here, Langfuse / Datadog / custom in the future) +consumes through a uniform user-readable surface: - :data:`current_correlation_id` — the per-invocation cross-backend join key. Set on every outermost ``invoke()`` call (caller-supplied - or auto-generated UUIDv4 per spec §3.1) and reset on return. User - code in node bodies, middleware, and observers reads it via + or auto-generated UUIDv4) and reset on return. User code in node + bodies, middleware, and observers reads it via :func:`current_correlation_id`. - :data:`_active_observers` — the observer set in scope for any code running INSIDE a node body. Read by capability backends that need - to emit observer events from outside the engine's per-step machinery - (e.g., the llm-provider span hook puts a NodeEvent-shaped record on - the engine's delivery queue, then those observers receive it). The - engine sets this around each ``chain(state)`` invocation via - ``try/finally`` so reset is guaranteed even on exception. + to emit observer events from outside the engine's per-step + machinery (e.g., the llm-provider span hook puts a NodeEvent-shaped + record on the engine's delivery queue, then those observers receive + it). The engine sets this around each ``chain(state)`` invocation + via ``try/finally`` so reset is guaranteed even on exception. These primitives live in the core package — no OpenTelemetry -dependency — because the spec §3.1 contract ("MUST propagate via the -language's idiomatic context primitive — Python ``ContextVar``") is -backend-agnostic. The OTel-specific surfacing lives under -``openarmature.observability.otel`` and is gated behind the -``[otel]`` extras. +dependency — because the contract is backend-agnostic. The +OTel-specific surfacing lives under ``openarmature.observability.otel`` +and is gated behind the ``[otel]`` extras. """ from __future__ import annotations @@ -37,7 +40,7 @@ # --------------------------------------------------------------------------- -# Correlation ID — spec observability §3.1 +# Correlation ID (observability spec §3.1) # --------------------------------------------------------------------------- @@ -48,10 +51,10 @@ def current_correlation_id() -> str | None: """Return the correlation ID for the current invocation, or ``None`` if no openarmature invocation is in scope. - Per spec §3.1 the correlation ID MUST be readable from anywhere - within an invocation's async call tree — node bodies, middleware, - observers — without explicit threading through function arguments. - This is the public reader. + The correlation ID is readable from anywhere within an + invocation's async call tree — node bodies, middleware, observers + — without explicit threading through function arguments. This is + the public reader. Returns ``None`` outside an invocation (e.g., at module import time, inside a test that runs without going through ``invoke()``). @@ -77,7 +80,7 @@ def _reset_correlation_id(token: Token[str | None]) -> None: # --------------------------------------------------------------------------- -# Invocation ID — spec observability §5.1 +# Invocation ID (observability spec §5.1) # # The framework-generated UUIDv4 that ties spans of one invocation # together within a single backend. Distinct from ``correlation_id`` @@ -96,10 +99,10 @@ def current_invocation_id() -> str | None: invocation, or ``None`` if no openarmature invocation is in scope. - Per spec observability §5.1 every invocation produces a unique - UUIDv4 ``invocation_id``, framework-generated, surfaced as the - ``openarmature.invocation_id`` attribute on the invocation span - + on every per-backend record. This is the public reader for + Every invocation produces a unique UUIDv4 ``invocation_id``, + framework-generated, surfaced as the + ``openarmature.invocation_id`` attribute on the invocation span + + on every per-backend record. This is the public reader for backend mappings (OTel, future Langfuse) that need to populate that attribute. """ @@ -136,7 +139,7 @@ def current_active_observers() -> tuple[SubscribedObserver, ...]: the engine's per-step machinery (e.g., the llm-provider span hook inside ``OpenAIProvider.complete``) reads this to find which observers should receive the event. Combined with the engine's - delivery queue, this preserves spec §6's strict serial ordering + delivery queue, this preserves strict serial event ordering across all event sources within an invocation. Returns an empty tuple when no invocation is active, by design — @@ -184,7 +187,7 @@ def current_dispatch() -> Callable[[NodeEvent], None] | None: Capability code emitting observer events from inside a node body calls this to put a ``NodeEvent``-shaped record on the engine's - delivery queue. The queue's serial worker preserves spec §6's + delivery queue. The queue's serial worker preserves per-invocation event ordering across all event sources (engine, checkpoint, LLM provider, future backends). """ diff --git a/src/openarmature/observability/otel/__init__.py b/src/openarmature/observability/otel/__init__.py index e29aeb06..4142d0c9 100644 --- a/src/openarmature/observability/otel/__init__.py +++ b/src/openarmature/observability/otel/__init__.py @@ -1,10 +1,15 @@ +# Spec: this subpackage is the OTel backend mapping. Realizes the +# observer-driven span lifecycle from observability spec §6 (RECOMMENDED +# path) and the logs-correlation glue from §7. Layering follows charter +# §3.1 principle 5 (core defines contracts; backends implement them). +# Plan: lift into a sibling ``openarmature-otel`` package at v1.0 launch +# alongside ``openarmature-eval``. + """OpenTelemetry backend mapping for openarmature observability. -Per charter §3.1 principle 5: the core defines observability contracts -(``correlation_id`` ContextVar, dispatch primitives); specific -backends implement them. This subpackage is extras-gated — install -with ``pip install openarmature[otel]`` to bring in -``opentelemetry-api`` and ``opentelemetry-sdk``. +This subpackage is extras-gated — install with +``pip install openarmature[otel]`` to bring in ``opentelemetry-api`` +and ``opentelemetry-sdk``. Importing this subpackage without the extras installed raises an informative :class:`ImportError` pointing the caller at the install @@ -15,14 +20,9 @@ Public surface: -- :class:`OTelObserver` — the observer-driven span lifecycle implementation - per spec observability §6 RECOMMENDED path. +- :class:`OTelObserver` — observer-driven span lifecycle. - :func:`install_log_bridge` — helper to wire the OTel Logs SDK to the stdlib ``logging`` root with ``correlation_id`` injection. - -Plan to lift into a sibling ``openarmature-otel`` package at the v1.0 -launch alongside ``openarmature-eval``; until then this subpackage -holds the implementation in-tree. """ from __future__ import annotations diff --git a/src/openarmature/observability/otel/logs.py b/src/openarmature/observability/otel/logs.py index aa706612..155c228a 100644 --- a/src/openarmature/observability/otel/logs.py +++ b/src/openarmature/observability/otel/logs.py @@ -1,9 +1,11 @@ -"""OTel Logs Bridge integration (spec observability §7). +# Spec: realizes observability §7 (logs correlation contract). + +"""OTel Logs Bridge integration. Provides :func:`install_log_bridge` — an opt-in helper that wires the stdlib :mod:`logging` root logger through the OTel Logs SDK so every log record emitted within an invocation carries the active -``trace_id``/``span_id`` plus ``openarmature.correlation_id``. +``trace_id`` / ``span_id`` plus ``openarmature.correlation_id``. Opt-in by design: users may have their own logging configuration we shouldn't override silently. Calling ``install_log_bridge(provider)`` @@ -37,16 +39,16 @@ def _install_correlation_id_factory() -> None: ROOT logger only fire for records originating directly on the root logger — Python's logging propagation walks ancestors' HANDLERS but not their filters. A filter on root therefore - misses every record from a child logger (the normal case; - every reasonable user does ``logger = logging.getLogger("module")``). - Spec §7 mandates the attribute appear on records emitted from - "anywhere within an invocation" — the factory hooks at record - construction, fires uniformly for every emit regardless of - which logger originated the record, and chains over any - user-installed factory rather than replacing it. - - Idempotent: re-calling skips installation if the current - factory is already the OA-installed one. + misses every record from a child logger (the normal case; every + reasonable user does ``logger = logging.getLogger("module")``). + The attribute MUST appear on records emitted from anywhere + within an invocation — the factory hooks at record construction, + fires uniformly for every emit regardless of which logger + originated the record, and chains over any user-installed + factory rather than replacing it. + + Idempotent: re-calling skips installation if the current factory + is already the OA-installed one. """ from openarmature.observability.correlation import current_correlation_id @@ -83,15 +85,15 @@ def install_log_bridge( installs a process-global :class:`logging.LogRecord` factory that injects ``openarmature.correlation_id`` on every record. - The factory placement matters per spec §7: "log records - emitted from anywhere within an invocation MUST carry - ``openarmature.correlation_id``." Filters added to the root + The factory placement matters: log records emitted from + anywhere within an invocation MUST carry + ``openarmature.correlation_id``. Filters added to the root logger fire only for records originating on root — Python's - propagation walks ancestor handlers but not ancestor filters - — so a root-logger filter misses every child-logger record. - The factory hook fires at record construction time, before any - logger or handler dispatch, so every record gets the - attribute regardless of which logger originated it. + propagation walks ancestor handlers but not ancestor filters — + so a root-logger filter misses every child-logger record. The + factory hook fires at record construction time, before any + logger or handler dispatch, so every record gets the attribute + regardless of which logger originated it. Idempotent: re-calling is a no-op (we check for the existing OA-tagged handler on the root logger AND for the OA-installed diff --git a/src/openarmature/observability/otel/observer.py b/src/openarmature/observability/otel/observer.py index a1487fbd..959e110c 100644 --- a/src/openarmature/observability/otel/observer.py +++ b/src/openarmature/observability/otel/observer.py @@ -1,44 +1,58 @@ -"""OTelObserver — observer-driven span lifecycle (spec observability §6). - -The observer subscribes to all three §6 phases (``started``, +# Spec mapping (observability): +# - Observer-driven span lifecycle realizes §6 (RECOMMENDED path). +# - Span status comes from §4.2 error-category mapping. +# - Internal span maps are keyed by ``invocation_id``, which is also +# surfaced as the ``openarmature.invocation_id`` span attribute per +# §5.1. +# - Subgraph dispatch span names come from §4.5 (parent graph's +# SubgraphNode name); hierarchy from §4.1/§4.3; detached trace mode +# from §4.4. +# - ``correlation_id`` cross-run join key surfaces as the +# ``openarmature.correlation_id`` span attribute (§3.1). +# - Private TracerProvider per §6 isolation requirement (prevents +# global-provider auto-instrumentation libraries from emitting +# duplicate spans alongside ours). + +"""OTelObserver — observer-driven span lifecycle. + +The observer subscribes to all three node-event phases (``started``, ``completed``, ``checkpoint_saved``) plus the LLM-provider events the ``OpenAIProvider`` enqueues from inside node bodies. On a ``started`` event it opens a leaf span and pushes it onto an in-flight map keyed by ``(namespace, attempt_index, fan_out_index)``; on the matching -``completed`` event it pops the span, applies §4.2 status mapping, -and closes it. +``completed`` event it pops the span, applies the status mapping, and +closes it. **Per-invocation state isolation.** All internal span maps are -outer-keyed by ``invocation_id`` (per spec §5.1: each invocation has -a fresh framework-minted UUIDv4). A single observer can be safely -shared across concurrent invocations (e.g., an ASGI service running +outer-keyed by ``invocation_id`` (each invocation has a fresh +framework-minted UUIDv4). A single observer can be safely shared +across concurrent invocations (e.g., an ASGI service running ``asyncio.gather([invoke(), invoke()])`` on one observer); each invocation's spans live in their own sub-dict, lazy-allocated on -first event. The ``correlation_id`` is the cross-run join key (spec -§3.1) and is set as the ``openarmature.correlation_id`` attribute on -every span — it is *not* the state-scoping key, because resume runs -preserve the correlation_id and would (incorrectly) cause the -resumed run's spans to inherit the prior invocation's trace. - -**No cross-event OTel context tokens.** Parent spans are resolved from -the observer's own internal maps within a single event handler's -scope — never from ``opentelemetry.context.get_current()``. Spans are -opened with ``context=set_span_in_context(parent_span)`` directly -rather than ``attach()``-ing tokens that would have to be ``detach()`` --ed on the matching completed event. This eliminates LIFO-violation -hazards under interleaved fan-out events and makes the observer -robust to dispatch ordering. +first event. The ``correlation_id`` is the cross-run join key set as +the ``openarmature.correlation_id`` attribute on every span — it is +*not* the state-scoping key, because resume runs preserve the +correlation_id and would (incorrectly) cause the resumed run's spans +to inherit the prior invocation's trace. + +**No cross-event OTel context tokens.** Parent spans are resolved +from the observer's own internal maps within a single event +handler's scope — never from ``opentelemetry.context.get_current()``. +Spans are opened with ``context=set_span_in_context(parent_span)`` +directly rather than ``attach()``-ing tokens that would have to be +``detach()``-ed on the matching completed event. This eliminates +LIFO-violation hazards under interleaved fan-out events and makes +the observer robust to dispatch ordering. Subtree isolation lives in dedicated dicts rather than the leaf-span key: - ``subgraph_spans`` — synthetic subgraph dispatch spans (the engine - wrapper is transparent per fixture 013, but observability §4.5 - mandates a span). Keyed by namespace prefix. Open lazily on the - first deeper-namespace event, close when subsequent events leave - the prefix. -- ``detached_roots`` — root spans for detached subgraphs (§4.4) and - per-instance detached fan-out roots. Each lives in its own fresh + wrapper is transparent but the observer mints a span anyway). + Keyed by namespace prefix. Open lazily on the first deeper- + namespace event, close when subsequent events leave the prefix. +- ``detached_roots`` — root spans for detached subgraphs and per- + instance detached fan-out roots. Each lives in its own fresh ``trace_id``; the parent's dispatch span carries an OTel :class:`Link` to the detached trace. - ``_invocation_span`` — root invocation span keyed by @@ -46,13 +60,13 @@ :meth:`shutdown`. Spans are emitted through a **private** :class:`TracerProvider` -constructed by this observer — never the OTel global. Per spec §6 -TracerProvider isolation, registering globally would cause every -auto-instrumentation library that writes to the global provider -(OpenInference, opentelemetry-instrumentation-openai, LiteLLM, etc.) -to emit duplicate spans alongside ours. +constructed by this observer — never the OTel global. Registering +globally would cause every auto-instrumentation library that writes +to the global provider (OpenInference, +opentelemetry-instrumentation-openai, LiteLLM, etc.) to emit +duplicate spans alongside ours. -Detached trace mode (§4.4) is implemented by minting a fresh +Detached trace mode is implemented by minting a fresh :class:`SpanContext` with a new ``trace_id`` when entering a configured-detached subgraph or fan-out; the parent's dispatch span carries an OTel :class:`Link` to the detached trace. diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py new file mode 100644 index 00000000..e02d5829 --- /dev/null +++ b/tests/test_docs_examples.py @@ -0,0 +1,37 @@ +"""Run every Python code block in ``docs/`` as a test. + +Uses pytest-examples to find each ```python ... ``` block under ``docs/`` +and execute it. Catches docs drift — if a refactor breaks a snippet's +imports or API call, this test fails before the docs site can mislead +a reader. + +Non-Python blocks (``bash``, ``toml``, etc.) are skipped — the +language filter happens at parametrize time so the test count stays +honest. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pytest_examples import CodeExample, EvalExample, find_examples + +DOCS_DIR = Path(__file__).parent.parent / "docs" + +PYTHON_EXAMPLES = [example for example in find_examples(DOCS_DIR) if example.prefix in ("python", "py")] + + +@pytest.mark.parametrize("example", PYTHON_EXAMPLES, ids=str) +def test_docs_example(example: CodeExample, eval_example: EvalExample) -> None: + # Only snippets under sections holding full runnable programs are + # executed (``getting-started/`` Quickstart, ``model-providers/`` + # Provider Protocol + skeleton). Concept-page snippets are + # illustrative — they reference names defined out-of-band (a + # builder local, a not-yet-defined class) and aren't meant to + # stand alone. Drift detection still covers the core API via the + # executed snippets plus the auto-generated mkdocstrings reference. + runnable_sections = ("getting-started", "model-providers") + if not any(section in str(example.path) for section in runnable_sections): + pytest.skip("illustrative snippet, not a standalone program") + eval_example.run(example) diff --git a/tests/test_examples_smoke.py b/tests/test_examples_smoke.py new file mode 100644 index 00000000..785e7767 --- /dev/null +++ b/tests/test_examples_smoke.py @@ -0,0 +1,48 @@ +"""Smoke test: load each example's ``main.py`` and invoke its +``build_graph()`` factory. + +We don't run the demo end-to-end — that would hit a local OpenAI- +compatible LLM endpoint that isn't available in CI. But loading the +module and compiling its graph catches: + +- syntax errors, +- accidental breakage in openarmature's public API that would only + otherwise surface when a user runs the demo, +- missing imports (e.g. a renamed symbol that the demo still + references), +- graph-compile failures in the demo's structure (dangling edges, + unreachable nodes, conflicting reducers, missing entry, etc.). + +``runpy.run_path`` with ``run_name`` set to a sentinel skips the +example's ``if __name__ == "__main__":`` block, so we get the +module-level import side-effects without firing any LLM calls. +``build_graph()`` is a convention every demo exposes — invoking it +exercises the same compile path the demo's ``main()`` does. +""" + +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + +EXAMPLES_DIR = Path(__file__).parent.parent / "examples" + +DEMOS = [ + "01-linear-pipeline", + "02-routing-and-subgraphs", + "03-explicit-subgraph-mapping", + "04-observer-hooks", + "05-nested-subgraphs", +] + + +@pytest.mark.parametrize("demo", DEMOS) +def test_example_loads(demo: str) -> None: + main_py = EXAMPLES_DIR / demo / "main.py" + assert main_py.exists(), f"missing: {main_py}" + module_globals = runpy.run_path(str(main_py), run_name="__not_main__") + build_graph = module_globals.get("build_graph") + assert callable(build_graph), f"{demo}/main.py missing build_graph() factory" + build_graph() diff --git a/tests/unit/test_llm_provider.py b/tests/unit/test_llm_provider.py index 6accd8d6..ad0b0969 100644 --- a/tests/unit/test_llm_provider.py +++ b/tests/unit/test_llm_provider.py @@ -6,7 +6,7 @@ exercise directly: per-role construction errors that fixtures only hit through the boundary, the tool-call-id verbatim guarantee, the canonical category-string contract, and the -``_classify_http_error`` mapping table. +``classify_http_error`` mapping table. """ from __future__ import annotations @@ -40,6 +40,7 @@ ToolMessage, Usage, UserMessage, + classify_http_error, validate_message_list, validate_tools, ) @@ -272,21 +273,10 @@ def test_transient_categories_excludes_terminal_categories() -> None: # --------------------------------------------------------------------------- -# _classify_http_error — wire-mapping table (spec §8.3) +# classify_http_error — wire-mapping table (spec §8.3) # --------------------------------------------------------------------------- -def _make_provider() -> OpenAIProvider: - """Build a provider for unit tests of ``_classify_http_error``. - - The transport exists only to satisfy provider construction; these - tests pass explicit ``httpx.Response`` instances directly into - ``_classify_http_error`` and never perform wire calls. - """ - transport = httpx.MockTransport(lambda _req: httpx.Response(204)) - return OpenAIProvider(base_url="http://test", model="m", api_key="k", transport=transport) - - def _wire_response( status: int, body: dict[str, object] | None = None, headers: dict[str, str] | None = None ) -> httpx.Response: @@ -299,26 +289,22 @@ def _wire_response( def test_classify_401_to_authentication() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(401)) + err = classify_http_error(_wire_response(401)) assert isinstance(err, ProviderAuthentication) def test_classify_403_to_authentication() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(403)) + err = classify_http_error(_wire_response(403)) assert isinstance(err, ProviderAuthentication) def test_classify_400_to_invalid_request() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(400)) + err = classify_http_error(_wire_response(400)) assert isinstance(err, ProviderInvalidRequest) def test_classify_404_with_model_not_found_to_invalid_model() -> None: - p = _make_provider() - err = p._classify_http_error( + err = classify_http_error( _wire_response( 404, {"error": {"code": "model_not_found", "message": "no such model"}}, @@ -328,30 +314,24 @@ def test_classify_404_with_model_not_found_to_invalid_model() -> None: def test_classify_404_without_model_marker_to_unavailable() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(404)) + err = classify_http_error(_wire_response(404)) assert isinstance(err, ProviderUnavailable) def test_classify_429_with_retry_after_to_rate_limit() -> None: - p = _make_provider() - err = p._classify_http_error( - _wire_response(429, {"error": {"message": "slow down"}}, {"Retry-After": "30"}) - ) + err = classify_http_error(_wire_response(429, {"error": {"message": "slow down"}}, {"Retry-After": "30"})) assert isinstance(err, ProviderRateLimit) assert err.retry_after == 30.0 def test_classify_429_without_retry_after_to_rate_limit_no_value() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(429)) + err = classify_http_error(_wire_response(429)) assert isinstance(err, ProviderRateLimit) assert err.retry_after is None def test_classify_503_with_model_not_loaded_to_model_not_loaded() -> None: - p = _make_provider() - err = p._classify_http_error( + err = classify_http_error( _wire_response( 503, {"error": {"type": "model_not_loaded", "message": "loading"}}, @@ -361,26 +341,22 @@ def test_classify_503_with_model_not_loaded_to_model_not_loaded() -> None: def test_classify_503_without_marker_to_unavailable() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(503)) + err = classify_http_error(_wire_response(503)) assert isinstance(err, ProviderUnavailable) def test_classify_500_to_unavailable() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(500)) + err = classify_http_error(_wire_response(500)) assert isinstance(err, ProviderUnavailable) def test_classify_502_to_unavailable() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(502)) + err = classify_http_error(_wire_response(502)) assert isinstance(err, ProviderUnavailable) def test_classify_504_to_unavailable() -> None: - p = _make_provider() - err = p._classify_http_error(_wire_response(504)) + err = classify_http_error(_wire_response(504)) assert isinstance(err, ProviderUnavailable) diff --git a/uv.lock b/uv.lock index eab208ae..5c343e66 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,101 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "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 = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "cairocffi" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, +] + +[[package]] +name = "cairosvg" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cairocffi" }, + { name = "cssselect2" }, + { name = "defusedxml" }, + { name = "pillow" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/07/e8412a13019b3f737972dea23a2c61ca42becafc16c9338f4ca7a0caa993/cairosvg-2.9.0.tar.gz", hash = "sha256:1debb00cd2da11350d8b6f5ceb739f1b539196d71d5cf5eb7363dbd1bfbc8dc5", size = 40877, upload-time = "2026-03-13T15:42:00.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl", hash = "sha256:4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68", size = 45962, upload-time = "2026-03-14T13:56:33.512Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -33,6 +128,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -42,6 +194,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[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.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -51,6 +288,28 @@ 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 = "cssselect2" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -60,6 +319,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "filelock" version = "3.28.0" @@ -69,6 +337,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189, upload-time = "2026-04-14T22:54:32.037Z" }, ] +[[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 = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -145,6 +434,408 @@ 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 = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + +[[package]] +name = "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 = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, +] + +[[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 = "mdformat" +version = "0.7.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" }, +] + +[[package]] +name = "mdformat-tables" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdformat" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload-time = "2024-08-23T23:41:33.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload-time = "2024-08-23T23:41:31.863Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "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 = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + +[[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-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[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-glightbox" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "selectolax" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/26/c793459622da8e31f954c6f5fb51e8f098143fdfc147b1e3c25bf686f4aa/mkdocs_glightbox-0.5.2.tar.gz", hash = "sha256:c7622799347c32310878e01ccf14f70648445561010911c80590cec0353370ac", size = 510586, upload-time = "2025-10-23T14:55:18.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/ca/03624e017e5ee2d7ce8a08d89f81c1e535eb3c30d7b2dc4a435ea3fbbeae/mkdocs_glightbox-0.5.2-py3-none-any.whl", hash = "sha256:23a431ea802b60b1030c73323db2eed6ba859df1a0822ce575afa43e0ea3f47e", size = 26458, upload-time = "2025-10-23T14:55:17.43Z" }, +] + +[[package]] +name = "mkdocs-llmstxt" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mdformat" }, + { name = "mdformat-tables" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f5/4c31cdffa7c09bf48d8c7a50d8342dc100abac98ac4150826bc11afc0c9f/mkdocs_llmstxt-0.5.0.tar.gz", hash = "sha256:b2fa9e6d68df41d7467e948a4745725b6c99434a36b36204857dbd7bb3dfe041", size = 33909, upload-time = "2025-11-20T14:02:24.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2b/82928cc9e8d9269cd79e7ebf015efdc4945e6c646e86ec1d4dba1707f215/mkdocs_llmstxt-0.5.0-py3-none-any.whl", hash = "sha256:753c699913d2d619a9072604b26b6dc9f5fb6d257d9b107857f80c8a0b787533", size = 12040, upload-time = "2025-11-20T14:02:23.483Z" }, +] + +[[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.optional-dependencies] +imaging = [ + { name = "cairosvg" }, + { name = "pillow" }, +] + +[[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 = "mkdocstrings" +version = "0.30.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + +[[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 = "nodeenv" version = "1.10.0" @@ -154,6 +845,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "openai" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, +] + [[package]] name = "openarmature" version = "0.5.0" @@ -176,10 +886,23 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-examples" }, { name = "pyyaml" }, { name = "ruff" }, { name = "types-pyyaml" }, ] +docs = [ + { name = "mike" }, + { name = "mkdocs" }, + { name = "mkdocs-glightbox" }, + { name = "mkdocs-llmstxt" }, + { name = "mkdocs-material", extra = ["imaging"] }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "pymdown-extensions" }, +] +examples = [ + { name = "openai" }, +] [package.metadata] requires-dist = [ @@ -197,10 +920,21 @@ dev = [ { name = "pyright", specifier = ">=1.1.360" }, { name = "pytest", specifier = ">=8.2" }, { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-examples", specifier = ">=0.0.13" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.5" }, { name = "types-pyyaml" }, ] +docs = [ + { name = "mike", specifier = ">=2.1,<3" }, + { name = "mkdocs", specifier = ">=1.6,<2" }, + { name = "mkdocs-glightbox", specifier = ">=0.4,<1" }, + { name = "mkdocs-llmstxt", specifier = ">=0.1,<1" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5,<10" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27,<1" }, + { name = "pymdown-extensions", specifier = ">=10.0,<11" }, +] +examples = [{ name = "openai", specifier = ">=1.40" }] [[package]] name = "opentelemetry-api" @@ -279,6 +1013,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] +[[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 = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -313,6 +1134,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.2" @@ -412,6 +1242,28 @@ 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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pyright" version = "1.1.408" @@ -454,6 +1306,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-examples" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "black" }, + { name = "pytest" }, + { name = "ruff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/71/4ae972fd95f474454aa450108ee1037830e7ba11840363e981b8d48fd16a/pytest_examples-0.0.18.tar.gz", hash = "sha256:9a464f007f805b113677a15e2f8942ebb92d7d3eb5312e9a405d018478ec9801", size = 21237, upload-time = "2025-05-06T07:46:10.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.2.2" @@ -467,6 +1345,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -513,6 +1420,33 @@ wheels = [ { 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 = "requests" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + [[package]] name = "ruff" version = "0.15.11" @@ -538,6 +1472,101 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] +[[package]] +name = "selectolax" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/1a/ce7768c1bff5cf07e3e12f904d54a63c3f85f78e3b299c0b561839d0238e/selectolax-0.4.8.tar.gz", hash = "sha256:cd703165b9a346be255e2ca5b4219e01009911977ac8a474d8ccb7e32e9a4fae", size = 4875521, upload-time = "2026-05-04T15:10:44.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/3c/9cf255f11d04cf203b61f5a85fb273bc85778c3ab5d2ad98ce892f7df3b4/selectolax-0.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbcd4cb837dec4b16a3ab6a8b2ec4388ea20c050092b68536dc9a60ce8f19b56", size = 2236812, upload-time = "2026-05-04T15:09:20.193Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/4c343f767fa61131fc03b3de278dfcff024485996d7d6c917b55b0f9ce16/selectolax-0.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1cdcf946dd46e11640ca0a0361945c3ed65627ba4bd219ccf84a53fab28c072", size = 2287194, upload-time = "2026-05-04T15:09:22.15Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7d/5a47a0d102709297b6b09473a58c7e9727955a0d6e0c8eba9b27574e3680/selectolax-0.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:074254670a8a00b36202ab0cd99ce8fb2a25b3b2f10072891a35a6a85c53f679", size = 2369192, upload-time = "2026-05-04T15:09:23.768Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/48b5cfa5b2803379855050310c876a6f68b7e53598f9252e5bb3fb911ff0/selectolax-0.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bbd33d348a555b37d5f922c421338db690500e5b579353ffa24e3bd23a04704", size = 2415401, upload-time = "2026-05-04T15:09:25.349Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/cf0ffe4668af87f2071253ad43cccf73ac6bc6324c13fefe38fdb198d54b/selectolax-0.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c0cf863f652ed6de58c9f3599173be70064827807f46ddd38ad82b185fead322", size = 2373885, upload-time = "2026-05-04T15:09:26.889Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/4de417c1bbc44c31453ac0582f578ddfdf4f4e29d55d451e4435cef7f4eb/selectolax-0.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ca2e1a9a06d1b1a7549d2828240f1888a172b60720baf8a2cb90936482d8b6b4", size = 2437684, upload-time = "2026-05-04T15:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e4/a465e3d7b641b7693c36a20401c1cd924bd9038dfbee195c11087de08aae/selectolax-0.4.8-cp312-cp312-win32.whl", hash = "sha256:b812691bbdf7c958b29956138917bd46cd78b96f2d4f51f56063f584f0d6c476", size = 1759388, upload-time = "2026-05-04T15:09:30.679Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/b7e681cf829f58be0927b10d606ec96126e3d863b11c4ba6cabb701a1b32/selectolax-0.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:877df05c9fb306ed6b4d094cbd655ad8e89ea4744d3597eb1e5217c7f71d4ea4", size = 1855778, upload-time = "2026-05-04T15:09:32.284Z" }, + { url = "https://files.pythonhosted.org/packages/2d/85/9b64306821eb38fb84510e43bd5e8685fbf179ce5717496522fdf208573c/selectolax-0.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:52db6be936a0f0648d9d8acbfdf16d4c5a16600cfc31db1b557b0effd8b164ce", size = 1805553, upload-time = "2026-05-04T15:09:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/678ea1250811a4b42686c506df43d3c752ce2158dc66fd4f60c39d4ae1d6/selectolax-0.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2c4bed19e0c3e34b877b6df607150725d84a38dda32bccb030d8744078eda6", size = 2236220, upload-time = "2026-05-04T15:09:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fe/1624eb5024e897bf4074bfc31f9e5e823160aed1ac14e7720e849a3d1109/selectolax-0.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a2d83e04bf0a9f0a8c9a6a7989df1844acc2242025f26293c1e12ce82b4a3f9", size = 2286820, upload-time = "2026-05-04T15:09:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/f0d4e4061de679f5f3d1858f50d31b09935fa97c05f8a2b2e46c344c74a8/selectolax-0.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8513c179ec9758d9bbc43a9e7e58fecfcfc1ec88e9100ed592cce2703c316b63", size = 2368949, upload-time = "2026-05-04T15:09:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2d/2ba76651c93907b2c3c0005d4745df94493e29bd15cfb9edd7c0e6a7a4d9/selectolax-0.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b6225fd6b5bec6be59bc07aeee17f50448c9b4769531afaa079c74650ebd2c5", size = 2415032, upload-time = "2026-05-04T15:09:40.93Z" }, + { url = "https://files.pythonhosted.org/packages/b8/08/a20f9ef0213b8f33c5164781e8cc18eeb059779ed8b4051df39cbfde3d50/selectolax-0.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:899b62117147fd804b43822e7af0c422730c6743b27b20b233c4896363b1e655", size = 2373656, upload-time = "2026-05-04T15:09:42.862Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8d/ba692cc70aa45feb55d8241e05f8b8bde97ed4c7d5b4d83b148d1f6ebc43/selectolax-0.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:573b67610d2876b67707eaaad71d28da5a0d4ce72ab4880a68abeacdbdd825b1", size = 2437386, upload-time = "2026-05-04T15:09:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/771e3e7ad7ef51ddafecf7b80e92421113cc4026fb951740009633d34d1d/selectolax-0.4.8-cp313-cp313-win32.whl", hash = "sha256:69cf8b1edbc2f6e401a1be0a2fc6eb1ea05679446053724a3ac399abea1e98d3", size = 1759310, upload-time = "2026-05-04T15:09:47.103Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4d/6635f0ba2d0f456f5f5ded29691fab431da94ae8403b88379da730337971/selectolax-0.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:e57a2c314b339ffd6da899bf7d37a3d850aa03b8bcbcbb25d183fdae4525ad6d", size = 1856663, upload-time = "2026-05-04T15:09:49.247Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2d/a9adae174d3b6e83eda10854e3921f298a2764f604bb6b8bea68461cb003/selectolax-0.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:9a593eaf1a6686b559e3f65f20d21c592933cf6c18a0a042a9f46d96a88f54d3", size = 1805441, upload-time = "2026-05-04T15:09:51.3Z" }, + { url = "https://files.pythonhosted.org/packages/25/60/d1a14de44725277d91111931d785edc8387de1e95fe0987766b1e73a899e/selectolax-0.4.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:412d8c3203d294a36bed5b01942d8f81724a6731458e257b0153f2f4425e3da9", size = 2252331, upload-time = "2026-05-04T15:09:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/4e/79/2455aab4dc66a7f6c49af5dda759eca3bdbd20f2fa4193eb1daf32abc93e/selectolax-0.4.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:45e02ed68d9888828edae568c17ebd055f265221c2784329ed63078283c4e0ea", size = 2304020, upload-time = "2026-05-04T15:09:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/04/81/ae320006cb5f327926d7888fabdec3181c1a4dbb6218ebe05f10f204fdd0/selectolax-0.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f7aa77dec4a04b8b63aeaa505c168ac2597df2be426783ede8d74d56e925b48", size = 2368262, upload-time = "2026-05-04T15:09:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b4/cf8c860906aeeb074a0babe070d399416b9ec80c1029e1ed187aed443936/selectolax-0.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fab78f7cab55c06a1a994f522adb5b4c5302aaacac60bdca7de1f8eaacaa9f9", size = 2414392, upload-time = "2026-05-04T15:09:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/ab5f27398bfa65d49e92b5846e0ed4bb92c45c5cfe759d3c14ba47845085/selectolax-0.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7923f90a5d51325cde8b47e41aa71a0f81429afb85d26966369389c5eaf99c6e", size = 2390375, upload-time = "2026-05-04T15:10:01.06Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3d/0045463e7b8e9229a422507aac28fa71fa5cf548c1410f47f1337fe54bdc/selectolax-0.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7af017e28bcdcea1d7aaeab329f78515fab3becf30c4c9408e0c9b8303be1393", size = 2454560, upload-time = "2026-05-04T15:10:03.135Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9c/628723b10aece08f963b8d38473433df66986b419c9789d130cae9ff61dd/selectolax-0.4.8-cp314-cp314-win32.whl", hash = "sha256:40f6de3a820983c1f3feacb60351677b69d0c829b63aa11c7bd4811bcbcb8f42", size = 1870694, upload-time = "2026-05-04T15:10:04.912Z" }, + { url = "https://files.pythonhosted.org/packages/34/e4/2d111f68cbd2ed1270d37b2fe23f9dbd2059cfa80f492ee19b3ad3b641b4/selectolax-0.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ce6668c260ada8880106f6d37cd073b53ac3342f04b8a771dacb6ea68d953285", size = 1965854, upload-time = "2026-05-04T15:10:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/f0042a20e4ca914e66d2f513491bfc92d907ead59d3a63c6e56e6130b006/selectolax-0.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:5125e171b16bbbdb76ea36140c165f183b1e99fe4170eb9b79a7141090bba51c", size = 1914520, upload-time = "2026-05-04T15:10:08.223Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3b/5d0087a277802a0054073d67919e560de3fada54497762f065ea5e54b0cb/selectolax-0.4.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:bc041e2554c4cce221401bbde42177a99670fdba8a60448f0ae65731fc08a0b5", size = 2266985, upload-time = "2026-05-04T15:10:10.405Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/1c398f0040f21897945d6a750dbc49bf1e0190f57490ba631bda4a421e86/selectolax-0.4.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:957fd757b0491e29f96235cb5ce0a46b8cdc8cce41f5032805387311d2504677", size = 2314767, upload-time = "2026-05-04T15:10:12.239Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/4ef2186699d1097f7275eade50cc27d41a1ecb8311b8d46c185e5266dd9d/selectolax-0.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d001ceff303937e0d05ecba4c1a8d39ea04840a653ccaecf59446667c16f65bc", size = 2374250, upload-time = "2026-05-04T15:10:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/9f/01/bb5fe5a69997282764174d4fdd0246d6ceb269c03806f07ad454d3fdc07c/selectolax-0.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f293e28183b329bed738918d18cd35966add6600bad2f3431470ffa18c0639e", size = 2425296, upload-time = "2026-05-04T15:10:16.493Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/5114524c7480c6f55791ef7ab82a4a923b0367553bc9f39541ed8b392117/selectolax-0.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3972b884caa78abf3710a0e7723dc705974dbdaef46d98a05e47c68493d86922", size = 2401425, upload-time = "2026-05-04T15:10:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/59/8d/e51946644e5abbe53d08634ac2251f73132d290116a9d349ec5d381c8ba0/selectolax-0.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a6aacac9d106a2c992b817b5c1d4a1aa2e825403a5c1354386657968fa4b9b15", size = 2462447, upload-time = "2026-05-04T15:10:20.026Z" }, + { url = "https://files.pythonhosted.org/packages/9b/54/2250ce95c2b4f47f43b5fb9a156db823faffe1623c95e42137c738234573/selectolax-0.4.8-cp314-cp314t-win32.whl", hash = "sha256:ccfb2d172db96401a8d4ebc2dc12f4f4f20208597f5e6510d3f6bdc5a49c46b5", size = 1919388, upload-time = "2026-05-04T15:10:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/4e576a67fdd3a44d76418a196b7850890af8ef254efbca3eabef1b70fad5/selectolax-0.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:5c34fd9a29a430c1f8800d94e7a2c4fd0b1aef10485b0871fbae914cde232c41", size = 2036030, upload-time = "2026-05-04T15:10:23.775Z" }, + { url = "https://files.pythonhosted.org/packages/31/58/1e080cf00d7c4712e6411cd9cd3c927ec6ad4adf25d280a73ef38aeeb68a/selectolax-0.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:136dfad919728023fcd13fc773a1c488204fb0efd9c1f9baf7bd815fbb35eec3", size = 1939289, upload-time = "2026-05-04T15:10:25.481Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260408" @@ -568,6 +1597,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.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 = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + [[package]] name = "virtualenv" version = "21.2.4" @@ -583,6 +1630,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, ] +[[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" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "wrapt" version = "2.1.2"