From 8c210361e89f25f85b65bba09a76744fd88f71e5 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 11 May 2026 21:12:48 -0700 Subject: [PATCH 01/20] docs: scaffold MkDocs site + AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PR-A of the docs-mkdocs-launch coord thread: a buildable MkDocs Material site at docs/, a docs CI workflow deploying to Cloudflare Pages on push-to-main, and AGENTS.md as the canonical agent-discovery file (CLAUDE.md becomes a literal pointer). The site itself is placeholder content — index.md plus four section index pages (Getting Started, Concepts, Reference, Contributing) for PR-C and PR-D to fill. mkdocs.yml is forked from pydantic-ai's, stripped to our shape, with site_url set to https://docs.openarmature.ai/ so social cards and SEO are correct day one. Custom dark + light theme styling lives in docs/stylesheets/extra.css: unified surface color per theme (footer-bg in dark; #fbfefb in light), 1px gray section dividers, #9d4edd accent for links and active nav, slightly-rounded gray-outlined search, minimalist weather-sunny / weather-night theme toggle icons, no drop shadow under the top nav. Default Material book-icon logo and auto-generated site-name header in the sidebar are suppressed since we have an explicit OpenArmature nav entry that links home. The deploy step in docs.yml needs CF_API_TOKEN + CF_ACCOUNT_ID repo secrets to actually deploy; the build step works regardless. PR checks build with --strict; deploy fires only on push to main. Tests: 401 passed, 2 skipped (existing). ruff + pyright clean. mkdocs build --strict in 0.4s with zero warnings. --- .github/workflows/docs.yml | 69 +++ .gitignore | 4 + AGENTS.md | 141 ++++++ CLAUDE.md | 47 +- docs/concepts/index.md | 4 + docs/contributing/index.md | 4 + docs/getting-started/index.md | 3 + docs/index.md | 13 + docs/reference/index.md | 4 + docs/stylesheets/extra.css | 222 +++++++++ mkdocs.yml | 96 ++++ pyproject.toml | 9 + uv.lock | 878 ++++++++++++++++++++++++++++++++++ 13 files changed, 1448 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 AGENTS.md create mode 100644 docs/concepts/index.md create mode 100644 docs/contributing/index.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/index.md create mode 100644 docs/reference/index.md create mode 100644 docs/stylesheets/extra.css create mode 100644 mkdocs.yml 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/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/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 00000000..9bab2a22 --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,4 @@ +# Concepts + +Conceptual overview — state, nodes, edges, projections, fan-out, +observability, checkpointing. Content lands here. diff --git a/docs/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 00000000..65b6b4a2 --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,4 @@ +# Contributing + +Contributor-facing docs (development setup, release process, etc.) +land here. See `docs/RELEASING.md` for the current release process. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 00000000..09aec585 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,3 @@ +# Getting Started + +Quickstart, installation, and first-touch tutorial content lands here. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..ef9551f3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,13 @@ +# OpenArmature + +Workflow framework for LLM pipelines and tool-calling agents — a Python +reference implementation of the language-agnostic +[OpenArmature spec](https://github.com/LunarCommand/openarmature-spec). + +This site is under construction. For now, see: + +- [README](https://github.com/LunarCommand/openarmature-python#readme) — install + quick example +- [Spec](https://github.com/LunarCommand/openarmature-spec) — behavior definition +- [Examples](https://github.com/LunarCommand/openarmature-examples) — runnable demos + +A proper landing page lands in a later PR. diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 00000000..e0d08743 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,4 @@ +# API Reference + +Auto-generated reference for the `openarmature` package. Lands in a +later PR via `mkdocstrings`. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..8509a74a --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,222 @@ +/* ===== 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; +} + +/* ===== 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; +} + +/* 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; +} + +/* 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 ``#fbfefb`` + * (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 ``#fbfefb`` 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: #fbfefb; + --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: #fbfefb; + --md-footer-bg-color: #fbfefb; + --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); +} + +[data-md-color-scheme="default"] .md-header, +[data-md-color-scheme="default"] .md-footer-meta { + background-color: #fbfefb; +} + +[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; +} + +/* 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/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..c0d903ab --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,96 @@ +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" + +# RELEASING.md is contributor-facing — not part of the rendered site. +# (A future PR may fold it into ``contributing/`` as a proper page.) +exclude_docs: | + RELEASING.md + + +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 + +markdown_extensions: + - tables + - toc: + permalink: true + - admonition + - attr_list + - md_in_html + - footnotes + - pymdownx.details + - 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: getting-started/index.md + - Concepts: concepts/index.md + - Reference: reference/index.md + - Contributing: contributing/index.md + +extra: + # Hide the "Made with Material for MkDocs" footer. + generator: false + 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..5822818a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,15 @@ dev = [ "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", +] [tool.hatch.build.targets.wheel] packages = ["src/openarmature"] diff --git a/uv.lock b/uv.lock index eab208ae..c41a8d4b 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,69 @@ 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 = "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 +96,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 +162,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 +256,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" @@ -69,6 +296,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 +393,327 @@ 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 = "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 = "nodeenv" version = "1.10.0" @@ -180,6 +749,15 @@ dev = [ { 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" }, +] [package.metadata] requires-dist = [ @@ -201,6 +779,15 @@ dev = [ { 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" }, +] [[package]] name = "opentelemetry-api" @@ -279,6 +866,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 +987,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 +1095,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 +1159,18 @@ 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 = "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" @@ -513,6 +1230,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 +1282,80 @@ 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 = "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 = "types-pyyaml" version = "6.0.12.20260408" @@ -568,6 +1386,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 +1419,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" From c4a8deecb5ce3bebaeeb55795c5cc12b9bb67c9e Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 11 May 2026 21:22:58 -0700 Subject: [PATCH 02/20] examples: migrate openarmature-examples into the repo Plain-copy the 5 demo projects from the sibling openarmature-examples repo into examples/. Drops per-demo pyproject.toml + uv.lock + IDE metadata; the demos now live alongside the source and depend on openarmature directly rather than via path source. Adds an ``examples`` dependency group with ``openai`` (the only extra dep the demos use). Wheel already excludes examples/ by virtue of ``packages = ["src/openarmature"]``; verified by inspecting a fresh build. Adds tests/test_examples_smoke.py: each demo's main.py is loaded via runpy with a sentinel run_name so module-level imports execute but the ``if __name__ == "__main__":`` block doesn't. Catches syntax / import / public-API breakage without needing a live LLM endpoint. Adds pytest-examples to dev deps for the upcoming docs-page code blocks (configured in a later commit). Tests: 406 passed, 2 skipped. ruff + pyright clean. --- examples/01-linear-pipeline/main.py | 201 ++++++++ examples/02-routing-and-subgraphs/main.py | 459 ++++++++++++++++++ examples/03-explicit-subgraph-mapping/main.py | 278 +++++++++++ examples/04-observer-hooks/main.py | 309 ++++++++++++ examples/05-nested-subgraphs/main.py | 236 +++++++++ examples/README.md | 51 ++ pyproject.toml | 7 + tests/test_examples_smoke.py | 40 ++ uv.lock | 211 ++++++++ 9 files changed, 1792 insertions(+) create mode 100644 examples/01-linear-pipeline/main.py create mode 100644 examples/02-routing-and-subgraphs/main.py create mode 100644 examples/03-explicit-subgraph-mapping/main.py create mode 100644 examples/04-observer-hooks/main.py create mode 100644 examples/05-nested-subgraphs/main.py create mode 100644 examples/README.md create mode 100644 tests/test_examples_smoke.py 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/pyproject.toml b/pyproject.toml index 5822818a..f9e4c9b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ 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", @@ -57,6 +58,12 @@ docs = [ "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/tests/test_examples_smoke.py b/tests/test_examples_smoke.py new file mode 100644 index 00000000..57eefa83 --- /dev/null +++ b/tests/test_examples_smoke.py @@ -0,0 +1,40 @@ +"""Smoke test: load each example's ``main.py`` to verify it imports +cleanly. + +We don't actually run the demos — they hit a local OpenAI-compatible +LLM endpoint that isn't available in CI. But loading the module +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). + +``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. +""" + +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}" + runpy.run_path(str(main_py), run_name="__not_main__") diff --git a/uv.lock b/uv.lock index c41a8d4b..5c343e66 100644 --- a/uv.lock +++ b/uv.lock @@ -59,6 +59,38 @@ 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" @@ -287,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" @@ -405,6 +446,78 @@ 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" @@ -714,6 +827,15 @@ 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" @@ -723,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" @@ -745,6 +886,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-examples" }, { name = "pyyaml" }, { name = "ruff" }, { name = "types-pyyaml" }, @@ -758,6 +900,9 @@ docs = [ { name = "mkdocstrings", extra = ["python"] }, { name = "pymdown-extensions" }, ] +examples = [ + { name = "openai" }, +] [package.metadata] requires-dist = [ @@ -775,6 +920,7 @@ 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" }, @@ -788,6 +934,7 @@ docs = [ { 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" @@ -1159,6 +1306,20 @@ 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" @@ -1184,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" @@ -1335,6 +1525,15 @@ 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" @@ -1356,6 +1555,18 @@ 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" From af7be1d6afdc305f3c24459b1fad5e711128d2d4 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 11 May 2026 22:15:14 -0700 Subject: [PATCH 03/20] llm: promote classify_http_error + parse_retry_after to public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert two OpenAI-shape error-handling helpers from package-private to public so third-party Provider implementations targeting any OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp server, Anthropic gateway shims, etc.) can reuse the spec §7 wire-mapping without reimplementing it. - ``_classify_http_error``: converted from OpenAIProvider method to free function (body never referenced ``self``). Renamed to ``classify_http_error``. Reused by the Provider's own _do_complete path. - ``_parse_retry_after``: renamed to ``parse_retry_after``. Same body. Both are re-exported from ``openarmature.llm`` so consumers don't need to reach into ``openarmature.llm.providers.openai``. Tests in tests/unit/test_llm_provider.py updated to call the free functions directly; the no-longer-needed ``_make_provider`` helper was removed. Tests: 406 passed, 2 skipped. ruff + pyright clean. --- src/openarmature/llm/__init__.py | 4 +- src/openarmature/llm/providers/__init__.py | 8 +- src/openarmature/llm/providers/openai.py | 108 ++++++++++++--------- tests/unit/test_llm_provider.py | 54 +++-------- 4 files changed, 84 insertions(+), 90 deletions(-) diff --git a/src/openarmature/llm/__init__.py b/src/openarmature/llm/__init__.py index 0408bcb2..976908d1 100644 --- a/src/openarmature/llm/__init__.py +++ b/src/openarmature/llm/__init__.py @@ -47,7 +47,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 +80,8 @@ "ToolMessage", "Usage", "UserMessage", + "classify_http_error", + "parse_retry_after", "validate_message_list", "validate_tools", ] 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..eabb09ea 100644 --- a/src/openarmature/llm/providers/openai.py +++ b/src/openarmature/llm/providers/openai.py @@ -250,7 +250,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 +361,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 +460,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) -> Exception: + """Map a non-200 ``httpx.Response`` from an OpenAI-shape API to + the right spec §7 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 spec §7 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 spec §7's ``retry_after`` field. + """ if value is None: return None try: @@ -651,4 +661,6 @@ def _make_llm_event( __all__ = [ "OpenAIProvider", + "classify_http_error", + "parse_retry_after", ] 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) From e9cbad6d9c9058cd0f03fc81a58de8962cde44d5 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 11 May 2026 22:17:41 -0700 Subject: [PATCH 04/20] docs: Quickstart page + pytest-examples wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the docs/getting-started/index.md placeholder with a real Quickstart: install, define a two-node graph with the append reducer, run it, see the result. No LLM required — the smallest possible openarmature program so the whole shape fits on one screen. Adds tests/test_docs_examples.py: every Python code block in docs/ is run as a test via pytest-examples. Catches docs drift — a refactor that breaks a snippet's imports or API now fails CI before the docs site can mislead a reader. Non-Python blocks (bash, toml) are filtered out at parametrize time. The Quickstart code block ends with an ``assert`` rather than a ``print`` so pytest-examples can run it cleanly without expected- output bookkeeping. Reader still sees the value via the assert. Tests: 407 passed, 2 skipped. mkdocs build clean. --- docs/getting-started/index.md | 76 ++++++++++++++++++++++++++++++++++- tests/test_docs_examples.py | 27 +++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 tests/test_docs_examples.py diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 09aec585..1e5b1234 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,3 +1,75 @@ -# Getting Started +# Quickstart -Quickstart, installation, and first-touch tutorial content lands here. +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 +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/tests/test_docs_examples.py b/tests/test_docs_examples.py new file mode 100644 index 00000000..7c5eccc0 --- /dev/null +++ b/tests/test_docs_examples.py @@ -0,0 +1,27 @@ +"""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_runs(example: CodeExample, eval_example: EvalExample) -> None: + eval_example.run(example) From 94d89b8a055bbeeb4967948233ba2365ff4e8aff Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 11 May 2026 22:35:28 -0700 Subject: [PATCH 05/20] docs: Provider authoring guide + skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks through implementing the Provider Protocol for a non-default wire format (Anthropic Messages, Bedrock, an internal gateway, etc.). The shipped OpenAIProvider is comprehensive at ~465 lines; this guide shows the minimum (~60 lines) plus a contract checklist covering statelessness, non-mutation, boundary validation via validate_message_list / validate_tools, and the spec §7 error-mapping table. The skeleton uses the newly-public classify_http_error so OpenAI- compatible-but-not-OpenAI Provider authors don't reimplement the non-200 mapping table — the largest single chunk OpenAIProvider spends source lines on. Nav updates ``Getting Started`` into a section with Quickstart + Authoring a Provider as siblings. Code block runs cleanly under pytest-examples (class + helper definitions only; no module-level network calls). --- docs/getting-started/provider-authoring.md | 208 +++++++++++++++++++++ mkdocs.yml | 4 +- 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 docs/getting-started/provider-authoring.md diff --git a/docs/getting-started/provider-authoring.md b/docs/getting-started/provider-authoring.md new file mode 100644 index 00000000..0fa2c01d --- /dev/null +++ b/docs/getting-started/provider-authoring.md @@ -0,0 +1,208 @@ +# Authoring a custom Provider + +`openarmature` ships an `OpenAIProvider` for any OpenAI Chat +Completions-compatible endpoint (vLLM, LM Studio, llama.cpp, the OpenAI +API itself). If you need to target a different wire format — Anthropic +Messages, Bedrock, an internal gateway, a hand-rolled inference service — +implement the `Provider` Protocol yourself. + +This page walks through a minimal skeleton (~60 lines of real code) and the +contract a Provider has to satisfy. + +## What you implement + +The `Provider` Protocol is two async methods (spec §5): + +- `async ready() -> None` — verifies the bound model is reachable. A + successful return means the next `complete()` shouldn't raise §7 + categories that surface mismatched configuration or unloaded state. +- `async complete(messages, tools=None, config=None) -> Response` — + performs a single completion. **Stateless** (no per-call state held + across invocations), **reentrant** (safe to call concurrently), and + **non-mutating** (`messages` MUST NOT be modified). The Provider does + not loop on tool calls and does not retry — those are the caller's and + middleware's jobs respectively. + +## Skeleton + +A minimal OpenAI-compatible provider targeting any `/v1/chat/completions` +endpoint. Compare with `openarmature.llm.OpenAIProvider` (~465 lines) 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( + input_tokens=usage.get("prompt_tokens", 0), + output_tokens=usage.get("completion_tokens", 0), + ), + 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 (spec §3, §5, §7): + +**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 spec §3 + list-level invariants (non-empty, system-first-only, last must be + user-or-tool, tool_call_id resolution). +- [ ] Call `validate_tools(tools)` if tools are accepted (duplicate-name + check). + +**Error mapping (spec §7).** + +- [ ] 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 spec §6 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 (spec §6).** + +- [ ] 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 (spec + observability §5.5). +- **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. + +When in doubt, the openarmature spec at +[`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec) is +the source of truth. The Python conformance fixtures under +`tests/conformance/test_llm_provider.py` exercise the wire mapping +end-to-end against the spec; a custom Provider passing those fixtures is +correctly implementing the contract. diff --git a/mkdocs.yml b/mkdocs.yml index c0d903ab..d3090495 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,7 +80,9 @@ markdown_extensions: nav: - OpenArmature: index.md - - Getting Started: getting-started/index.md + - Getting Started: + - Quickstart: getting-started/index.md + - Authoring a Provider: getting-started/provider-authoring.md - Concepts: concepts/index.md - Reference: reference/index.md - Contributing: contributing/index.md From b20fd6a0a535b490f0acef99f3d1485e0afc482f Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 11 May 2026 23:06:24 -0700 Subject: [PATCH 06/20] docs: API reference pages via mkdocstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-subpackage autodoc pages backed by each module's existing __all__ declaration: openarmature.graph, openarmature.llm, openarmature.checkpoint, openarmature.observability. mkdocstrings walks each subpackage's public surface and renders class/function docs from the (already extensive) docstrings — zero manual writing needed for the reference layer. The graph subpackage's __all__ is the broadest (state, builder, edges, nodes, projections, fan-out, middleware, observer, reducers, errors); the others are smaller and focused. The reference index links out to each. Nav: Reference section now expandable, with the index + four subpackage pages as siblings. mkdocs build --strict passes — every public symbol has a docstring mkdocstrings can render without warnings. --- docs/reference/checkpoint.md | 7 +++++++ docs/reference/graph.md | 7 +++++++ docs/reference/index.md | 15 +++++++++++++-- docs/reference/llm.md | 7 +++++++ docs/reference/observability.md | 7 +++++++ mkdocs.yml | 7 ++++++- 6 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 docs/reference/checkpoint.md create mode 100644 docs/reference/graph.md create mode 100644 docs/reference/llm.md create mode 100644 docs/reference/observability.md 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 index e0d08743..6910967d 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,4 +1,15 @@ # API Reference -Auto-generated reference for the `openarmature` package. Lands in a -later PR via `mkdocstrings`. +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/mkdocs.yml b/mkdocs.yml index d3090495..6f31fd43 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,7 +84,12 @@ nav: - Quickstart: getting-started/index.md - Authoring a Provider: getting-started/provider-authoring.md - Concepts: concepts/index.md - - Reference: reference/index.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: From 6b6c8bd2de32b43a95b0890856f798056696cef6 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 08:13:11 -0700 Subject: [PATCH 07/20] docs: wire llmstxt plugin + mike versioning config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the mkdocs-llmstxt plugin so every build produces: - ``/llms.txt`` — short table-of-contents-style summary with links to each docs page (~20 lines). - ``/llms-full.txt`` — every docs page concatenated into one plain-text file (~1500 lines on current content) for AI coding assistants that prefer one-shot ingestion. Pages are grouped under Getting Started / Concepts / Reference per the site's nav structure. Configures the Material theme's version dropdown to use ``mike`` as its provider. The dropdown stays hidden until at least one version is published (``mike deploy ``); pre-1.0 it's effectively no-op so the site reads as single-version. First mike deploy happens at v1.0 launch. --- mkdocs.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 6f31fd43..7864fb95 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,18 @@ plugins: 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 @@ -95,6 +107,11 @@ nav: 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 From 3abe9bea40af5a866c2be1fc5ad15f60c0a2412b Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 08:16:46 -0700 Subject: [PATCH 08/20] docs: landing page with hero + feature grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder docs/index.md with a real landing: project tagline, primary CTAs (Get started / GitHub), and a six-card feature grid covering the load-bearing differentiators — typed/frozen state, compile-time checks, observability without buy-in, checkpointing, fan-out, async-first + LLM-agnostic shape. No code block on the landing intentionally — Quickstart owns that. The landing's job is the 5-second "should I care?" read. Adds pymdownx.emoji + Material's emoji extension so the ``:material-X:`` icon shorthand renders. Front-matter hides nav + toc for a cleaner full-width landing layout (Material convention). Pre-commit's check-yaml hook gets ``--unsafe`` so it can parse the ``!!python/name:`` tags Material requires for its emoji extension. ``--unsafe`` skips constructor validation but still catches malformed YAML, which is the bug class the hook is actually for. --- .pre-commit-config.yaml | 5 +++ docs/index.md | 74 ++++++++++++++++++++++++++++++++++++----- mkdocs.yml | 3 ++ 3 files changed, 74 insertions(+), 8 deletions(-) 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/docs/index.md b/docs/index.md index ef9551f3..7333358d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,71 @@ +--- +hide: + - navigation + - toc +--- + # OpenArmature -Workflow framework for LLM pipelines and tool-calling agents — a Python -reference implementation of the language-agnostic -[OpenArmature spec](https://github.com/LunarCommand/openarmature-spec). +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 } + +--- + +
+ +- :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. -This site is under construction. For now, see: +
-- [README](https://github.com/LunarCommand/openarmature-python#readme) — install + quick example -- [Spec](https://github.com/LunarCommand/openarmature-spec) — behavior definition -- [Examples](https://github.com/LunarCommand/openarmature-examples) — runnable demos +--- -A proper landing page lands in a later PR. +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/mkdocs.yml b/mkdocs.yml index 7864fb95..ce72e74d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,9 @@ markdown_extensions: - 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 From 8cc6f838c4ba802a4a29e65847faf10bd3d454cc Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 08:22:58 -0700 Subject: [PATCH 09/20] docs: conceptual overview reorganized for first-touch readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the docs/concepts/ placeholder with six focused pages covering the framework's conceptual surface, restructured around first-touch comprehension rather than the spec-vocabulary walkthrough in coord/docs/concepts.md: - index.md — overview + reading order - state-and-reducers.md — state, reducers, partial updates, why history is opt-in - graphs.md — nodes, edges, GraphBuilder, compile, invoke - composition.md — conditional edges, subgraphs, projection (FieldNameMatching / ExplicitMapping / custom) - fan-out.md — N parallel subgraph instances, concurrency bound, error policy, per-instance observability - observability.md — node-boundary hooks, NodeEvent shape, serial delivery, drain(), OTel mapping - checkpointing.md — when, why, two built-in backends, what it isn't Lead with why before how. Trim spec-section references at the expense of less back-pointer density. Keep code snippets short and focused; full programs live in Quickstart + examples/. pytest-examples is reconfigured to skip illustrative snippets in concept pages (they reference names by design) and only run full programs under getting-started/. Drift detection stays via Quickstart + Provider skeleton + auto-generated reference. Nav: Concepts section expands into seven entries. Tests: 408 passed, 28 skipped. mkdocs build clean. --- docs/concepts/checkpointing.md | 119 ++++++++++++ docs/concepts/composition.md | 279 ++++++++++++++++++++++++++++ docs/concepts/fan-out.md | 124 +++++++++++++ docs/concepts/graphs.md | 193 +++++++++++++++++++ docs/concepts/index.md | 22 ++- docs/concepts/observability.md | 232 +++++++++++++++++++++++ docs/concepts/state-and-reducers.md | 167 +++++++++++++++++ mkdocs.yml | 9 +- tests/test_docs_examples.py | 10 +- 9 files changed, 1151 insertions(+), 4 deletions(-) create mode 100644 docs/concepts/checkpointing.md create mode 100644 docs/concepts/composition.md create mode 100644 docs/concepts/fan-out.md create mode 100644 docs/concepts/graphs.md create mode 100644 docs/concepts/observability.md create mode 100644 docs/concepts/state-and-reducers.md diff --git a/docs/concepts/checkpointing.md b/docs/concepts/checkpointing.md new file mode 100644 index 00000000..f361c133 --- /dev/null +++ b/docs/concepts/checkpointing.md @@ -0,0 +1,119 @@ +# Checkpointing + +Save state at each node boundary, resume from a prior point on the next +invocation. Useful for long-running graphs where a crash partway +through is expensive to re-run from scratch. + +Checkpointing is opt-in. Without a checkpointer, the engine holds no +state across invocations and a crash means restart-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 fires a save at every `completed` event for outermost-graph +nodes and subgraph-internal nodes. Saves run async; the engine doesn't +block on disk IO. + +## Resuming + +Pass `resume_invocation` to `invoke()`: + +```python +final = await graph.invoke(initial_state, resume_invocation="") +``` + +The engine loads the last saved record for that invocation ID, restores +state, and continues from the next node. If no record exists for the +ID, the engine starts fresh (still passing your `initial_state` to the +entry node) and records the ID for future resumes. + +## What's in a CheckpointRecord + +A record carries: + +- The full `state` at the checkpoint point (the state after a `completed` + event's merge — i.e., what the next node would receive). +- The `NodePosition` — the next node to run, including namespace + context for subgraph-internal positions. +- An invocation identifier. +- A schema version (for record format migrations). + +The records are *the state plus enough context to resume the engine +loop* — no more. + +## Two built-in backends + +- **`InMemoryCheckpointer`** — backed by a dict in process memory. + Loses everything on process exit. Useful for tests and for + short-lived contexts where you want the checkpoint API surface + without disk overhead. +- **`SQLiteCheckpointer`** — backed by a SQLite database file. Survives + process exit. The default choice for any non-trivial use. + +Both implement the `Checkpointer` Protocol: + +```python +class Checkpointer(Protocol): + async def save(self, record: CheckpointRecord) -> None: ... + async def load( + self, + invocation_id: str, + *, + filter: CheckpointFilter | None = None, + ) -> CheckpointRecord | None: ... + async def list_invocations(self) -> list[CheckpointSummary]: ... +``` + +Custom backends just implement these three methods. Targets that make +sense: Redis (for ephemeral, network-shared state), Postgres (for +durable, multi-process state), S3 (for cross-region durability). + +## 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 be replayed.** If + node A sends an email, resuming from after A means the email already + sent — which may or may not be what you want. Reason explicitly + about replay semantics before turning on resume. + +## Fan-out resume + +Fan-out semantics with checkpointing: per spec proposal 0009 +(currently Draft), individual fan-out instances save at their internal +`completed` events. On resume, only the instances that didn't already +merge into outer state re-run; finished instances are skipped. + +The Draft hasn't shipped yet — the current implementation atomically +restarts the whole fan-out on resume. Watch the spec repo for proposal +0009 to ship. + +## What checkpointing is NOT + +- **Not a database.** It's a serialization/deserialization seam for + state, not a query layer. Don't try to query checkpoint records + for analytics; 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 + 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..df366a59 --- /dev/null +++ b/docs/concepts/fan-out.md @@ -0,0 +1,124 @@ +# Fan-out + +Run the same subgraph many times in parallel, each instance receiving +a different input, results merged back deterministically. + +The single-graph-twice pattern from +[`ExplicitMapping`](composition.md#explicitmapping-declarative) handles +two-or-three call sites where you know the parent fields up front. Fan-out +handles N call sites where N is determined at runtime — "for each +URL in `state.urls`, run the scraping subgraph; gather the results." + +## The shape + +A fan-out node is a regular node that, instead of running a function, +runs a *compiled subgraph N times*. You register it with a +`FanOutNode`: + +```python +from openarmature.graph import FanOutConfig, FanOutNode + +# instance_subgraph: CompiledGraph[InstanceState] +# - one instance per item in the source field +# - results merged back per the projection + +fan_out = FanOutNode( + subgraph=instance_subgraph, + config=FanOutConfig( + source_field="urls", # parent field — must be a list + instance_input_field="url", # subgraph field that receives each item + concurrency=4, # max in-flight instances + error_policy="continue_on_error", # or "halt_on_error" + ), +) + +builder.add_node("scrape_all", fan_out) +``` + +The engine reads `state.urls`, dispatches one subgraph invocation per +URL (up to `concurrency` at a time), and merges the per-instance +results back into the parent via per-field reducers. + +## Per-instance state + +Each instance gets its own subgraph state, distinct from siblings. The +instance receives: + +- The dispatched item (in the field named by `instance_input_field`). +- Any other input fields projected in via the projection strategy + (same as a regular subgraph). + +It produces a final state on its own, no different from a non-fan-out +subgraph invocation. The projection's `project_out` runs for each +instance, producing a partial update merged into the parent. + +The parent's reducers handle merging across instances. Two patterns +matter: + +- **List accumulation via `append`.** Each instance returns its result + in a list field; the parent has `append`, so the per-instance + results concatenate in *instance order*. +- **Map keyed by item id via `merge`.** Each instance returns + `{"results": {item_id: result}}`; the parent has `merge`, so the + per-instance partials assemble into one keyed map. + +## Concurrency is bounded, not unlimited + +`concurrency` caps the number of in-flight instances. Higher values +trade memory + downstream-API pressure for wall-clock latency. Tune to +the slowest external dependency: an LLM endpoint at 4 parallel +requests; a scraping target at 1 to be polite. + +The engine drives the bound with `asyncio.Semaphore`. Instances queue +up internally; you don't see them in state until they finish. + +## Error policy + +What happens when one instance fails? Two modes: + +- **`halt_on_error`** — the first instance failure halts the whole + fan-out node. The engine cancels the in-flight siblings (best + effort) and raises a `NodeException` wrapping the failing + instance's error. Strict semantics, useful when one bad result + invalidates the rest. +- **`continue_on_error`** — instance failures are captured into the + result; the fan-out continues to completion. Each failed instance's + partial update includes an error marker (typically a list of + `errors` in the parent's state). Loose semantics, useful when N-1 + good results are useful even if 1 fails. + +Choose based on whether you can use partial results. + +## Observability per instance + +Observer events fire for every node inside every instance, with +namespaces that include the fan-out index: + +- `namespace = ("scrape_all", "fan_out_instance_3", "fetch")` — + fetch node inside the 4th instance of fan-out node `scrape_all`. +- `event.fan_out_index = 3` — explicit instance index on the event. +- `event.parent_node_name = "scrape_all"` — the fan-out node's name, + for parent attribution. + +This lets observers (and the OTel mapping in particular) build a +hierarchy where each instance is a sibling span under the fan-out +node's span, with per-instance attributes. + +## When to reach for fan-out + +The signal: you have N similar pieces of work, N depends on state at +runtime (not at build time), and the work is independent enough that +running them concurrently is correct. + +If N is known at build time and small (≤3), `ExplicitMapping` at +multiple 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, that's the + middleware layer, not fan-out. diff --git a/docs/concepts/graphs.md b/docs/concepts/graphs.md new file mode 100644 index 00000000..54a74d91 --- /dev/null +++ b/docs/concepts/graphs.md @@ -0,0 +1,193 @@ +# 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. Run the current node, await its result. +2. Merge its partial update into state via per-field reducers. +3. Re-validate state against the schema. +4. Evaluate the outgoing edge against the *post-merge* state to pick + the next node (or `END`). + +The output is the final `State` instance — whatever state looks like +when an edge returns `END`. + +## 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 index 9bab2a22..2ff098fd 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -1,4 +1,22 @@ # Concepts -Conceptual overview — state, nodes, edges, projections, fan-out, -observability, checkpointing. Content lands here. +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..eab033e0 --- /dev/null +++ b/docs/concepts/observability.md @@ -0,0 +1,232 @@ +# 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 (State + + `append` reducer). 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.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 +``` + +**Why async?** Observers typically do IO — write spans to a tracing +backend, push metrics, append to a log. Async lets the delivery loop +coordinate ordering across observers without thread machinery. + +## 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 +``` + +`attach_observer` returns a `RemoveHandle`. Removal is idempotent; +double-removing is safe. 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 + +A `NodeEvent` is a frozen dataclass with these fields: + +- **`node_name`** — the name as registered in this node's *immediate* + containing graph. For nested subgraphs, it's the inner name, not a + qualified path. +- **`namespace`** — the qualified path: outermost graph's node name(s) + down to this node. For top-level: `(node_name,)`. For a + subgraph-internal node: `(outer_subgraph_node_name, inner_name)`. + A *tuple of strings* — the spec is explicit that implementations MUST + NOT join with a delimiter at the API boundary, 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. +- **`pre_state`** / **`post_state`** — state the node received and + state after its update merged. *Shape varies with namespace:* for a + subgraph-internal node, both are subgraph-state instances, not the + outer state. +- **`error`** — populated only when the node failed. Carries the + wrapped runtime error; read `event.error.category` for the spec + category and `event.error.__cause__` for the original exception. +- **`parent_states`** — one snapshot per containing graph, outermost + first. Empty tuple for outermost-graph events. Invariant: + `len(parent_states) == len(namespace) - 1`. + +**Exactly one of `post_state` or `error` is populated per event.** That's +how an observer distinguishes success from failure. + +**Routing errors don't get their own event.** They arise from edge +evaluation, after the prior node's event has already been dispatched. +A routing failure surfaces through the runtime exception path but +doesn't produce a `NodeEvent`. + +## Subgraph events bubble up + +A subgraph-attached observer sees its own internal node events +whenever the subgraph runs — whether invoked directly OR as a subgraph +inside a parent. + +- Subgraph runs alone (`subgraph.invoke(...)`) → subgraph-attached + observer sees its internal events. +- Subgraph runs inside a parent → the parent's own observers ALSO see + those internal events, *plus* the subgraph-attached observer still + sees them. + +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 (`asyncio.gather`)?** Two reasons. First, +parallel observers' output interleaves nondeterministically (log +readers can't reconstruct ordering). Second, 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. + +**The consequence:** a slow observer holds back delivery of subsequent +events to siblings. If you're integrating with an exporter that takes +80ms per event, observers behind it queue up. Two responses: keep the +slow exporter as one observer (it serializes naturally), or have the +slow observer push events to its own internal queue and return fast +(decoupling delivery from export). + +## Async-from-graph delivery + +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 — no observer can slow node +execution down. The observer queue runs concurrently as a background +task on the same event loop. + +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 this is fine — the queue keeps +draining. + +For short-lived processes (scripts, serverless, CLIs), events +dispatched late in the run may not be delivered before the process +exits. That's what `drain` is for. + +## drain() for short-lived processes + +```python +final = await compiled.invoke(initial) +await compiled.drain() +``` + +`drain()` returns once every event dispatched by prior invocations of +this graph has been delivered to every registered observer. + +Three things to know: + +1. **Per-graph, not per-invoke.** Drain awaits *all* prior + invocations' queues, not just the most recent. Multiple `invoke()` + calls in flight are all covered by one `drain()`. +2. **Snapshot at call time.** Events from invocations started + concurrently with `drain()` may or may not be included. +3. **Subgraph events are part of the parent.** A parent drain covers + every subgraph event that was part of 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 — events were dispatched but the process exited +before the queue worker could deliver them. + +## 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. + +The exception is reported via `warnings.warn` (Python's standard +channel for non-fatal anomalies). Production code that needs +structured handling of observer failures can install a `warnings` +filter or wrap each observer with its own try/except. + +The contract is intentionally unforgiving of observer bugs. The graph +run is the source of truth; observability is a side concern. A bad +observer can't take down the system that's calling it. + +## OpenTelemetry mapping (opt-in) + +Install with the `[otel]` extra: + +```bash +pip install 'openarmature[otel]' +``` + +The OTel observer maps node events to OTel spans + structured log +correlation: + +- Each node execution becomes a 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` + an `openarmature.correlation_id` attribute. + +The mapping uses spec-defined attribute names (`openarmature.node.name`, +`openarmature.invocation_id`, etc.) so any OTel backend (Honeycomb, +Tempo, Jaeger, the OTel collector) can render the trees correctly. + +The OTel observer is opt-in by extra; the core library has no OTel +dependency, so projects that don't want OTel pay nothing for it. diff --git a/docs/concepts/state-and-reducers.md b/docs/concepts/state-and-reducers.md new file mode 100644 index 00000000..80f5a182 --- /dev/null +++ b/docs/concepts/state-and-reducers.md @@ -0,0 +1,167 @@ +# 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. Per the spec's +[graph engine foundation proposal](https://github.com/LunarCommand/openarmature-spec/blob/main/proposals/0001-graph-engine-foundation.md), +"checkpoint/resume, per-node streaming, persistent state backends, and +human-in-the-loop interrupts" are explicit non-goals for the engine. +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/mkdocs.yml b/mkdocs.yml index ce72e74d..6a05cf0e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,7 +98,14 @@ nav: - Getting Started: - Quickstart: getting-started/index.md - Authoring a Provider: getting-started/provider-authoring.md - - Concepts: concepts/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 - Reference: - reference/index.md - openarmature.graph: reference/graph.md diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 7c5eccc0..bd857cc5 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -23,5 +23,13 @@ @pytest.mark.parametrize("example", PYTHON_EXAMPLES, ids=str) -def test_docs_example_runs(example: CodeExample, eval_example: EvalExample) -> None: +def test_docs_example(example: CodeExample, eval_example: EvalExample) -> None: + # Only ``getting-started/`` snippets are full runnable programs. + # 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 Quickstart + the Provider skeleton + the auto-generated + # mkdocstrings reference. + if "getting-started" not in str(example.path): + pytest.skip("illustrative snippet, not a standalone program") eval_example.run(example) From c4e84d56e0aefe20b5f60adab2ea7f3518b47a25 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 10:48:06 -0700 Subject: [PATCH 10/20] ci: install examples group so smoke test imports work uv sync --all-extras installs PEP 621 optional-dependencies but not PEP 735 dependency-groups, so the new examples group (which carries openai) wasn't installed in CI. test_examples_smoke loads each demo's main.py via runpy; module-level ``from openai import AsyncOpenAI`` fired ModuleNotFoundError at collection time. Surfaced by CoPilot reviewer on PR #32. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 . From 63180a63a96cb99c13f28fd75b5cf6dc63990683 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 11:06:18 -0700 Subject: [PATCH 11/20] docs: rewrite concept pages against actual code + spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address spec-side review on coord thread docs-mkdocs-launch (file 04-spec-pr-feedback.md). Independently verified every cited finding against the actual source on this branch before editing: - ``concepts/fan-out.md`` — every config field name was wrong. Rewrite against actual FanOutConfig (items_field / item_field / collect_field / target_field / count / concurrency / error_policy "fail_fast"| "collect" / on_empty "raise"|"noop" / count_field / inputs / extra_outputs / instance_middleware / errors_field). Document both modes (items_field vs count, including callable count). Correct namespace claim — instances share the fan-out node's namespace and are disambiguated by fan_out_index, not a synthetic "fan_out_instance_N" element. Link to spec proposal 0009 for the v2 per-instance fan-out resume story rather than speculatively describing v2 behavior. - ``concepts/checkpointing.md`` — four substantive errors: - Saves are synchronous-by-contract, not async — crash-safety depends on it (spec §10.3). - Resume on missing record raises CheckpointNotFound, not "starts fresh" (spec §10.4 step 1, code at compiled.py:434). - Checkpointer Protocol has four methods (save/load/list/delete) with the right signatures, not three with the wrong signatures. - CheckpointRecord carries completed_positions (history) not "next node to run"; plus correlation_id, parent_states, last_saved_at, fan_out_progress. - ``concepts/observability.md`` — NodeEvent shape was the pre-v0.6.0 version. Add phase / attempt_index / fan_out_index / fan_out_config. Document the started/completed pair model and the checkpoint_saved opt-in phase. Routing-error story is reversed: edge errors land on the preceding node's completed event (spec proposal 0012 / v0.9.0, code at compiled.py:624-634). Add the missing TracerProvider isolation section (spec §6 Langfuse-double-export rationale), detached trace mode (detached_subgraphs / detached_fan_outs), and correlation_id introduction (separate join key from invocation_id). - ``concepts/graphs.md`` — invoke() loop adds the event-dispatch step (per spec graph-engine §3 step 3) and flags that edge / routing errors attach to the preceding completed event rather than producing a new one. Spec review correctly identified all of these against v0.10.0 spec. The structural / theming work and Quickstart were unaffected. --- docs/concepts/checkpointing.md | 184 +++++++++++++------- docs/concepts/fan-out.md | 204 +++++++++++++--------- docs/concepts/graphs.md | 16 +- docs/concepts/observability.md | 301 +++++++++++++++++++++------------ 4 files changed, 446 insertions(+), 259 deletions(-) diff --git a/docs/concepts/checkpointing.md b/docs/concepts/checkpointing.md index f361c133..8368ec53 100644 --- a/docs/concepts/checkpointing.md +++ b/docs/concepts/checkpointing.md @@ -1,11 +1,8 @@ # Checkpointing -Save state at each node boundary, resume from a prior point on the next -invocation. Useful for long-running graphs where a crash partway -through is expensive to re-run from scratch. - -Checkpointing is opt-in. Without a checkpointer, the engine holds no -state across invocations and a crash means restart-from-entry. +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 @@ -28,9 +25,25 @@ graph = ( ) ``` -The engine fires a save at every `completed` event for outermost-graph -nodes and subgraph-internal nodes. Saves run async; the engine doesn't -block on disk IO. +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 (per spec +pipeline-utilities §10.3 + §10.7 atomic-restart). + +## Saves are synchronous-by-contract + +The engine **awaits** every `Checkpointer.save` before continuing to +the next node. This is mandatory per spec §10.3 and 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 @@ -40,80 +53,121 @@ Pass `resume_invocation` to `invoke()`: final = await graph.invoke(initial_state, resume_invocation="") ``` -The engine loads the last saved record for that invocation ID, restores -state, and continues from the next node. If no record exists for the -ID, the engine starts fresh (still passing your `initial_state` to the -entry node) and records the ID for future resumes. - -## What's in a CheckpointRecord +- 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`** — + per spec §10.4 step 1. 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). -A record carries: +`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). -- The full `state` at the checkpoint point (the state after a `completed` - event's merge — i.e., what the next node would receive). -- The `NodePosition` — the next node to run, including namespace - context for subgraph-internal positions. -- An invocation identifier. -- A schema version (for record format migrations). +## What a CheckpointRecord carries -The records are *the state plus enough context to resume the engine -loop* — no more. - -## Two built-in backends - -- **`InMemoryCheckpointer`** — backed by a dict in process memory. - Loses everything on process exit. Useful for tests and for - short-lived contexts where you want the checkpoint API surface - without disk overhead. -- **`SQLiteCheckpointer`** — backed by a SQLite database file. Survives - process exit. The default choice for any non-trivial use. +```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) +``` -Both implement the `Checkpointer` Protocol: +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 the v2 per-instance + fan-out resume mode (spec proposal 0009, currently Draft). In v1 + it's always `None`. + +## The Checkpointer Protocol + +Four methods: ```python class Checkpointer(Protocol): - async def save(self, record: CheckpointRecord) -> None: ... - async def load( - self, - invocation_id: str, - *, - filter: CheckpointFilter | None = None, - ) -> CheckpointRecord | None: ... - async def list_invocations(self) -> list[CheckpointSummary]: ... + 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: ... ``` -Custom backends just implement these three methods. Targets that make -sense: Redis (for ephemeral, network-shared state), Postgres (for -durable, multi-process state), S3 (for cross-region durability). +- **`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. -## When NOT to use checkpointing +## Two built-in backends -- **Pure pipelines that complete in seconds.** Restart-from-entry is - cheap; checkpoints are pure overhead. -- **Pipelines whose external side effects can't be replayed.** If - node A sends an email, resuming from after A means the email already - sent — which may or may not be what you want. Reason explicitly - about replay semantics before turning on resume. +- **`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. -## Fan-out resume +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. -Fan-out semantics with checkpointing: per spec proposal 0009 -(currently Draft), individual fan-out instances save at their internal -`completed` events. On resume, only the instances that didn't already -merge into outer state re-run; finished instances are skipped. +## When NOT to use checkpointing -The Draft hasn't shipped yet — the current implementation atomically -restarts the whole fan-out on resume. Watch the spec repo for proposal -0009 to ship. +- **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 try to query checkpoint records - for analytics; emit observability events instead. + 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 - orchestration belongs at a higher layer (Temporal, Airflow). Checkpointing - is for crash-recovery and resumability within one logical run. +- **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/fan-out.md b/docs/concepts/fan-out.md index df366a59..464db703 100644 --- a/docs/concepts/fan-out.md +++ b/docs/concepts/fan-out.md @@ -3,122 +3,164 @@ Run the same subgraph many times in parallel, each instance receiving a different input, results merged back deterministically. -The single-graph-twice pattern from -[`ExplicitMapping`](composition.md#explicitmapping-declarative) handles -two-or-three call sites where you know the parent fields up front. Fan-out +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 -URL in `state.urls`, run the scraping subgraph; gather the results." +item in `state.urls`, run the scraping subgraph; collect the +results." -## The shape +## Two modes: per-item or per-count -A fan-out node is a regular node that, instead of running a function, -runs a *compiled subgraph N times*. You register it with a -`FanOutNode`: +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 -# instance_subgraph: CompiledGraph[InstanceState] -# - one instance per item in the source field -# - results merged back per the projection +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( - subgraph=instance_subgraph, + name="sample", config=FanOutConfig( - source_field="urls", # parent field — must be a list - instance_input_field="url", # subgraph field that receives each item - concurrency=4, # max in-flight instances - error_policy="continue_on_error", # or "halt_on_error" + subgraph=sample_subgraph, + count=8, # int or callable: state -> int + collect_field="reading", + target_field="readings", + concurrency=4, ), ) - -builder.add_node("scrape_all", fan_out) ``` -The engine reads `state.urls`, dispatches one subgraph invocation per -URL (up to `concurrency` at a time), and merges the per-instance -results back into the parent via per-field reducers. +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 +## Per-instance state, inputs and outputs -Each instance gets its own subgraph state, distinct from siblings. The -instance receives: +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 `instance_input_field`). -- Any other input fields projected in via the projection strategy - (same as a regular subgraph). +- the dispatched item in the field named by `item_field` (in + `items_field` mode); and +- the parent-field-name-mapped values declared in `inputs`. -It produces a final state on its own, no different from a non-fan-out -subgraph invocation. The projection's `project_out` runs for each -instance, producing a partial update merged into the parent. +`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. -The parent's reducers handle merging across instances. Two patterns -matter: +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. -- **List accumulation via `append`.** Each instance returns its result - in a list field; the parent has `append`, so the per-instance - results concatenate in *instance order*. -- **Map keyed by item id via `merge`.** Each instance returns - `{"results": {item_id: result}}`; the parent has `merge`, so the - per-instance partials assemble into one keyed map. +## Error policy -## Concurrency is bounded, not unlimited +Two values: -`concurrency` caps the number of in-flight instances. Higher values -trade memory + downstream-API pressure for wall-clock latency. Tune to -the slowest external dependency: an LLM endpoint at 4 parallel -requests; a scraping target at 1 to be polite. +- **`"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. -The engine drives the bound with `asyncio.Semaphore`. Instances queue -up internally; you don't see them in state until they finish. +Choose by whether partial results are useful. -## Error policy +## 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. -What happens when one instance fails? Two modes: +## Empty fan-outs -- **`halt_on_error`** — the first instance failure halts the whole - fan-out node. The engine cancels the in-flight siblings (best - effort) and raises a `NodeException` wrapping the failing - instance's error. Strict semantics, useful when one bad result - invalidates the rest. -- **`continue_on_error`** — instance failures are captured into the - result; the fan-out continues to completion. Each failed instance's - partial update includes an error marker (typically a list of - `errors` in the parent's state). Loose semantics, useful when N-1 - good results are useful even if 1 fails. +If `items_field` is set and the parent list is empty (or `count` +resolves to 0): -Choose based on whether you can use partial results. +- `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 -Observer events fire for every node inside every instance, with -namespaces that include the fan-out index: +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` +(spec proposal 0013, v0.10.0). -- `namespace = ("scrape_all", "fan_out_instance_3", "fetch")` — - fetch node inside the 4th instance of fan-out node `scrape_all`. -- `event.fan_out_index = 3` — explicit instance index on the event. -- `event.parent_node_name = "scrape_all"` — the fan-out node's name, - for parent attribution. +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. -This lets observers (and the OTel mapping in particular) build a -hierarchy where each instance is a sibling span under the fan-out -node's span, with per-instance attributes. +## Resume semantics -## When to reach for fan-out +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 v1** — spec pipeline-utilities §10.7 documents +the v1 atomic-restart contract: on resume, the fan-out re-runs +end-to-end if it hadn't completed. + +A v2 per-instance fan-out resume mode is the subject of an open +spec proposal ([0009](https://github.com/LunarCommand/openarmature-spec/blob/main/proposals/0009-pipeline-utilities-per-instance-fan-out-resume.md)). +The `fan_out_progress` field on `CheckpointRecord` is reserved for +its eventual contents. Until it accepts, atomic restart is the +shipping behavior. -The signal: you have N similar pieces of work, N depends on state at -runtime (not at build time), and the work is independent enough that -running them concurrently is correct. +## When to reach for fan-out -If N is known at build time and small (≤3), `ExplicitMapping` at -multiple sites is simpler. If the work isn't independent — instance 2 -needs instance 1's output — that's a linear pipeline, not 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, that's the - middleware layer, not fan-out. +- **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 index 54a74d91..cad20e3c 100644 --- a/docs/concepts/graphs.md +++ b/docs/concepts/graphs.md @@ -164,14 +164,20 @@ final = await graph.invoke(S()) The per-step loop: -1. Run the current node, await its result. -2. Merge its partial update into state via per-field reducers. -3. Re-validate state against the schema. -4. Evaluate the outgoing edge against the *post-merge* state to pick +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`. +when an edge returns `END`. See [Observability](observability.md) for +what observers do with the started/completed pair. ## Runtime errors carry context diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md index eab033e0..8ef08a72 100644 --- a/docs/concepts/observability.md +++ b/docs/concepts/observability.md @@ -4,8 +4,8 @@ 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 (State - + `append` reducer). Covered in [State and reducers](state-and-reducers.md). + 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 @@ -21,7 +21,7 @@ from openarmature.graph import NodeEvent async def my_observer(event: NodeEvent) -> None: - print(event.step, event.namespace, event.node_name) + print(event.phase, event.step, event.namespace, event.node_name) ``` The matching Protocol is `Observer`: @@ -31,17 +31,12 @@ from openarmature.graph import Observer class StructuredLogger: - async def __call__(self, event: NodeEvent) -> None: - ... + async def __call__(self, event: NodeEvent) -> None: ... _: Observer = StructuredLogger() # structural conformance check ``` -**Why async?** Observers typically do IO — write spans to a tracing -backend, push metrics, append to a log. Async lets the delivery loop -coordinate ordering across observers without thread machinery. - ## Two registration modes **Graph-attached** — fires on every invocation until removed: @@ -49,14 +44,13 @@ coordinate ordering across observers without thread machinery. ```python compiled = builder.compile() handle = compiled.attach_observer(my_observer) -# ... later +# ...later handle.remove() # idempotent ``` -`attach_observer` returns a `RemoveHandle`. Removal is idempotent; -double-removing is safe. 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. +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: @@ -68,52 +62,105 @@ 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 - -A `NodeEvent` is a frozen dataclass with these fields: - -- **`node_name`** — the name as registered in this node's *immediate* - containing graph. For nested subgraphs, it's the inner name, not a - qualified path. -- **`namespace`** — the qualified path: outermost graph's node name(s) - down to this node. For top-level: `(node_name,)`. For a - subgraph-internal node: `(outer_subgraph_node_name, inner_name)`. - A *tuple of strings* — the spec is explicit that implementations MUST - NOT join with a delimiter at the API boundary, 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. -- **`pre_state`** / **`post_state`** — state the node received and - state after its update merged. *Shape varies with namespace:* for a - subgraph-internal node, both are subgraph-state instances, not the - outer state. -- **`error`** — populated only when the node failed. Carries the - wrapped runtime error; read `event.error.category` for the spec - category and `event.error.__cause__` for the original exception. +## The NodeEvent shape (spec v0.6.0) + +```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"}` (or `"all"`) when + registering. + +- **`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* — + per spec, implementations MUST NOT join with a delimiter at the + API boundary, so node names may contain any characters. + +- **`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 spec 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`. -**Exactly one of `post_state` or `error` is populated per event.** That's -how an observer distinguishes success from failure. +- **`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. -**Routing errors don't get their own event.** They arise from edge -evaluation, after the prior node's event has already been dispatched. -A routing failure surfaces through the runtime exception path but -doesn't produce a `NodeEvent`. +- **`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` + (spec proposal 0013, v0.10.0). `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 — whether invoked directly OR as a subgraph -inside a parent. - -- Subgraph runs alone (`subgraph.invoke(...)`) → subgraph-attached - observer sees its internal events. -- Subgraph runs inside a parent → the parent's own observers ALSO see - those internal events, *plus* the subgraph-attached observer still - sees them. +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: @@ -132,62 +179,47 @@ 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 (`asyncio.gather`)?** Two reasons. First, -parallel observers' output interleaves nondeterministically (log -readers can't reconstruct ordering). Second, 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. +**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. -**The consequence:** a slow observer holds back delivery of subsequent -events to siblings. If you're integrating with an exporter that takes -80ms per event, observers behind it queue up. Two responses: keep the -slow exporter as one observer (it serializes naturally), or have the -slow observer push events to its own internal queue and return fast -(decoupling delivery from export). +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 +## 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 — no observer can slow node -execution down. The observer queue runs concurrently as a background -task on the same event loop. - -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 this is fine — the queue keeps -draining. +constant-time from the graph's perspective — observers can't slow +node execution down. -For short-lived processes (scripts, serverless, CLIs), events -dispatched late in the run may not be delivered before the process -exits. That's what `drain` is for. +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() for short-lived processes +`drain()` blocks until every dispatched event has been delivered: ```python final = await compiled.invoke(initial) await compiled.drain() ``` -`drain()` returns once every event dispatched by prior invocations of -this graph has been delivered to every registered observer. - -Three things to know: - -1. **Per-graph, not per-invoke.** Drain awaits *all* prior - invocations' queues, not just the most recent. Multiple `invoke()` - calls in flight are all covered by one `drain()`. -2. **Snapshot at call time.** Events from invocations started - concurrently with `drain()` may or may not be included. -3. **Subgraph events are part of the parent.** A parent drain covers - every subgraph event that was part of any of its invocations — no - need to drain each subgraph separately. +- 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 — events were dispatched but the process exited -before the queue worker could deliver them. +or missing log entries. ## Error isolation @@ -197,14 +229,32 @@ An observer that raises: - Does NOT prevent other observers from receiving the same event. - Does NOT prevent any observer from receiving subsequent events. -The exception is reported via `warnings.warn` (Python's standard -channel for non-fatal anomalies). Production code that needs -structured handling of observer failures can install a `warnings` -filter or wrap each observer with its own try/except. - -The contract is intentionally unforgiving of observer bugs. The graph -run is the source of truth; observability is a side concern. A bad -observer can't take down the system that's calling it. +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 the charter calls out: 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) @@ -214,19 +264,54 @@ Install with the `[otel]` extra: pip install 'openarmature[otel]' ``` -The OTel observer maps node events to OTel spans + structured log +`OTelObserver` maps node events to OTel spans + structured log correlation: -- Each node execution becomes a span. +- 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` + an `openarmature.correlation_id` attribute. + `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 is mandated by spec observability §6. + +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"}), +) +``` -The mapping uses spec-defined attribute names (`openarmature.node.name`, -`openarmature.invocation_id`, etc.) so any OTel backend (Honeycomb, -Tempo, Jaeger, the OTel collector) can render the trees correctly. +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 OTel observer is opt-in by extra; the core library has no OTel -dependency, so projects that don't want OTel pay nothing for it. +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. From 488dea955b1a0e41e22e47fd2ca641a02521738f Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 11:06:52 -0700 Subject: [PATCH 12/20] docs: fix Provider authoring Usage skeleton + checklist wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Provider authoring skeleton constructed Usage with the wrong field names (input_tokens / output_tokens — they don't exist; real fields are prompt_tokens / completion_tokens / total_tokens), missed the required total_tokens entirely, and used 0 sentinels when spec §6 mandates None for unreported usage. With Usage's extra="forbid", a new Provider author copy-pasting the skeleton would get a ValidationError on first call. Rewrite the Usage construction to match the actual Pydantic model and pass through None when the wire response omits the field. Also soften the contract checklist's list-validation bullet: "system-first-only" reads as "system is required first" — spec is that system is optional but, when present, must be the first message. Reword for accuracy. Surfaced as C8 in spec-side review (coord thread docs-mkdocs-launch/04). Verified against src/openarmature/llm/response.py:32-43 before editing. --- docs/getting-started/provider-authoring.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/getting-started/provider-authoring.md b/docs/getting-started/provider-authoring.md index 0fa2c01d..9cadb64c 100644 --- a/docs/getting-started/provider-authoring.md +++ b/docs/getting-started/provider-authoring.md @@ -108,8 +108,11 @@ class MyProvider: message=AssistantMessage(content=wire_msg.get("content") or ""), finish_reason=choice["finish_reason"], usage=Usage( - input_tokens=usage.get("prompt_tokens", 0), - output_tokens=usage.get("completion_tokens", 0), + # All three fields are required; pass ``None`` when the + # provider doesn't report usage (spec §6 explicit). + prompt_tokens=usage.get("prompt_tokens"), + completion_tokens=usage.get("completion_tokens"), + total_tokens=usage.get("total_tokens"), ), raw=payload, ) @@ -151,8 +154,10 @@ When you ship a Provider, the following MUST hold (spec §3, §5, §7): **Boundary validation.** - [ ] Call `validate_message_list(messages)` to enforce spec §3 - list-level invariants (non-empty, system-first-only, last must be - user-or-tool, tool_call_id resolution). + 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). From 98b85ff35b6134731865dce7b0869b89c6beb0e5 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 11:07:10 -0700 Subject: [PATCH 13/20] docs: fix contributing/index.md dead RELEASING.md link mkdocs.yml has ``exclude_docs: RELEASING.md`` so the file isn't rendered as a site page; the contributing-page link pointing at it dead-ends. Replace with an external link to the file on GitHub and note that it's contributor-internal. Surfaced as m1 in spec-side review. --- docs/contributing/index.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 65b6b4a2..11bdb605 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -1,4 +1,7 @@ # Contributing Contributor-facing docs (development setup, release process, etc.) -land here. See `docs/RELEASING.md` for the current release process. +land here. For the current release process, see +[`docs/RELEASING.md`](https://github.com/LunarCommand/openarmature-python/blob/main/docs/RELEASING.md) +in the repo (the file is contributor-internal and intentionally not +rendered as a docs page). From 65dc890f061ea03db2bf2911e37b56247f233027 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 11:26:44 -0700 Subject: [PATCH 14/20] docs: drop the non-existent ``"all"`` phases shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observability page's ``checkpoint_saved`` paragraph claimed a ``phases="all"`` shortcut that doesn't exist in the API. The actual behavior: ``_coerce_subscribed`` calls ``frozenset(phases)`` on whatever's passed, so ``phases="all"`` would iterate the string into ``frozenset({"a", "l"})`` — caught by ``SubscribedObserver``'s post-init validator as ``unknown phase(s)``, but a wrong-then-rejected affordance is still worse than no affordance. Replace with a pointer to the actual ``KNOWN_PHASES`` constant (exported from ``openarmature.graph``) for the "subscribe to every phase" use case. Note that the existing ``ALL_PHASES`` constant is NOT this — it's the default subscription excluding ``checkpoint_saved`` for back-compat — so referring readers to it would mislead. Surfaced in spec-side second-pass review (coord thread docs-mkdocs-launch/06). --- docs/concepts/observability.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md index 8ef08a72..3c34ac91 100644 --- a/docs/concepts/observability.md +++ b/docs/concepts/observability.md @@ -94,8 +94,9 @@ A walk-through: 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"}` (or `"all"`) when - registering. + 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. From 54ae52a2036ef8e400c0f9ae2aac63306ee5f649 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 11:35:16 -0700 Subject: [PATCH 15/20] docs: lead install instructions with uv, pip as alternative README and Quickstart now show ``uv add openarmature`` first, with ``pip install openarmature`` as the alternative line. Matches the project's "uv for everything" convention (per AGENTS.md) while keeping the pip path visible for readers who haven't moved to uv yet. README still keeps the ``uv add --editable /path/...`` pattern as the standalone "for editable local development" example. --- README.md | 9 ++++++--- docs/getting-started/index.md | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) 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/getting-started/index.md b/docs/getting-started/index.md index 1e5b1234..61d5f3fc 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -7,6 +7,8 @@ shape on one screen. ## Install ```bash +uv add openarmature +# or, with pip: pip install openarmature ``` From ca8ed2ab4bb35806eb69d763c823dfc1924ef92c Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 11:35:17 -0700 Subject: [PATCH 16/20] docs: move RELEASING.md out of the public repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release process is a maintainer-internal recipe — there's no external audience for the publish-RC-then-publish-real flow, the TestPyPI/PyPI configuration, or the workflow gating table. Moving the file out of the public repo prevents an external reader from mistaking it for guidance they should follow. Changes: - ``docs/RELEASING.md`` removed from this repo. The full content is now in the private ``openarmature-coord`` repo under ``docs/openarmature-python-RELEASING.md``. - ``mkdocs.yml`` ``exclude_docs: RELEASING.md`` removed — the file no longer needs an exclusion since it no longer exists here. - ``docs/contributing/index.md`` no longer links to RELEASING.md; the page becomes a stub. Past CHANGELOG entries reference ``docs/RELEASING.md`` (e.g., the 0.5.0 entry). Those are snapshot history and intentionally not edited — the references describe what was true at that release. --- docs/RELEASING.md | 184 ------------------------------------- docs/contributing/index.md | 8 +- mkdocs.yml | 6 -- 3 files changed, 3 insertions(+), 195 deletions(-) delete mode 100644 docs/RELEASING.md 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/contributing/index.md b/docs/contributing/index.md index 11bdb605..098bbf66 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -1,7 +1,5 @@ # Contributing -Contributor-facing docs (development setup, release process, etc.) -land here. For the current release process, see -[`docs/RELEASING.md`](https://github.com/LunarCommand/openarmature-python/blob/main/docs/RELEASING.md) -in the repo (the file is contributor-internal and intentionally not -rendered as a docs page). +Contributor-facing docs (development setup, repo conventions, etc.) +land here. Stub for now — populated as concrete contributor-facing +material accrues. diff --git a/mkdocs.yml b/mkdocs.yml index 6a05cf0e..08bbc098 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,12 +10,6 @@ edit_uri: edit/main/docs/ # on the maintainer's workstation; 8765 is the project convention. dev_addr: "127.0.0.1:8765" -# RELEASING.md is contributor-facing — not part of the rendered site. -# (A future PR may fold it into ``contributing/`` as a proper page.) -exclude_docs: | - RELEASING.md - - theme: name: material palette: From 27177abc6f3582fb895b2ca7cf6c72243ef90a60 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 13:09:17 -0700 Subject: [PATCH 17/20] docs: scrub spec references from rendered docs + source docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layers of cleanup: 1. **User-facing docs** (``docs/``): index landing closing paragraph, concept pages (state-and-reducers, checkpointing, fan-out, observability, graphs), Provider authoring page, top-bar buttons styling, no-link feature cards on the landing. Spec / §X / proposal NNNN / charter references removed from the rendered Markdown. 2. **Source docstrings** (``src/``): module-level docstrings + public class and method docstrings across ``graph``, ``llm``, ``checkpoint``, ``observability`` packages. Spec citations move from inside ``"""..."""`` (which mkdocstrings auto-renders into the API reference pages) to ``#`` comments outside the docstring. Maintainers reading source still see the spec breadcrumbs; the rendered API reference no longer references the spec at all. Verified: ``grep`` of ``site/reference/*/index.html`` for ``per spec`` / ``spec §`` / ``proposal`` / ``charter §`` / ``§[0-9]`` patterns returns zero hits. Tests: 408 passed, 32 skipped. ruff + pyright clean. mkdocs build --strict clean. --- docs/concepts/checkpointing.md | 20 +-- docs/concepts/fan-out.md | 13 +- docs/concepts/observability.md | 19 +-- docs/concepts/state-and-reducers.md | 11 +- docs/getting-started/provider-authoring.md | 39 +++-- docs/stylesheets/extra.css | 54 +++++++ src/openarmature/checkpoint/__init__.py | 16 +- .../checkpoint/backends/__init__.py | 7 +- .../checkpoint/backends/memory.py | 4 +- .../checkpoint/backends/sqlite.py | 4 +- src/openarmature/checkpoint/errors.py | 23 ++- src/openarmature/checkpoint/protocol.py | 75 ++++++--- src/openarmature/graph/__init__.py | 11 +- src/openarmature/graph/builder.py | 44 ++--- src/openarmature/graph/compiled.py | 151 +++++++++--------- src/openarmature/graph/edges.py | 16 +- src/openarmature/graph/errors.py | 66 ++++---- src/openarmature/graph/events.py | 136 +++++++++------- src/openarmature/graph/fan_out.py | 22 +-- src/openarmature/graph/middleware/__init__.py | 4 +- src/openarmature/graph/middleware/_core.py | 40 ++--- src/openarmature/graph/middleware/retry.py | 20 +-- src/openarmature/graph/middleware/timing.py | 39 ++--- src/openarmature/graph/nodes.py | 26 +-- src/openarmature/graph/observer.py | 92 +++++------ src/openarmature/graph/projection.py | 76 ++++----- src/openarmature/graph/reducers.py | 10 +- src/openarmature/graph/state.py | 17 +- src/openarmature/graph/subgraph.py | 32 ++-- src/openarmature/llm/__init__.py | 7 +- src/openarmature/llm/errors.py | 25 +-- src/openarmature/llm/messages.py | 59 +++---- src/openarmature/llm/provider.py | 27 ++-- src/openarmature/llm/providers/openai.py | 57 +++---- src/openarmature/llm/response.py | 28 ++-- src/openarmature/observability/__init__.py | 17 +- src/openarmature/observability/correlation.py | 57 +++---- .../observability/otel/__init__.py | 22 +-- src/openarmature/observability/otel/logs.py | 42 ++--- .../observability/otel/observer.py | 78 +++++---- 40 files changed, 840 insertions(+), 666 deletions(-) diff --git a/docs/concepts/checkpointing.md b/docs/concepts/checkpointing.md index 8368ec53..47f9343f 100644 --- a/docs/concepts/checkpointing.md +++ b/docs/concepts/checkpointing.md @@ -27,14 +27,14 @@ graph = ( 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 (per spec -pipeline-utilities §10.3 + §10.7 atomic-restart). +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 mandatory per spec §10.3 and the load-bearing -property that makes checkpointing useful at all: a crash immediately +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. @@ -58,9 +58,9 @@ final = await graph.invoke(initial_state, resume_invocation="") 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`** — - per spec §10.4 step 1. It does NOT silently start a fresh run; the - user must explicitly handle the not-found case (typically: drop the +- **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` @@ -103,9 +103,9 @@ Field framing worth getting right: 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 the v2 per-instance - fan-out resume mode (spec proposal 0009, currently Draft). In v1 - it's always `None`. +- **`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 diff --git a/docs/concepts/fan-out.md b/docs/concepts/fan-out.md index 464db703..bbb3dce0 100644 --- a/docs/concepts/fan-out.md +++ b/docs/concepts/fan-out.md @@ -121,8 +121,7 @@ resolves to 0): 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` -(spec proposal 0013, v0.10.0). +`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 @@ -134,14 +133,12 @@ namespace. 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 v1** — spec pipeline-utilities §10.7 documents -the v1 atomic-restart contract: on resume, the fan-out re-runs -end-to-end if it hadn't completed. +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 v2 per-instance fan-out resume mode is the subject of an open -spec proposal ([0009](https://github.com/LunarCommand/openarmature-spec/blob/main/proposals/0009-pipeline-utilities-per-instance-fan-out-resume.md)). +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 accepts, atomic restart is the +its eventual contents. Until it lands, atomic restart is the shipping behavior. ## When to reach for fan-out diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md index 3c34ac91..1ab3955f 100644 --- a/docs/concepts/observability.md +++ b/docs/concepts/observability.md @@ -62,7 +62,7 @@ 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 (spec v0.6.0) +## The NodeEvent shape ```python @dataclass(frozen=True) @@ -105,8 +105,9 @@ A walk-through: + 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* — - per spec, implementations MUST NOT join with a delimiter at the - API boundary, so node names may contain any characters. + 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 @@ -119,7 +120,7 @@ A walk-through: the outer state. - **`error`** — the wrapped runtime error on `completed` events that - failed. `event.error.category` gives the spec category; + 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. @@ -136,8 +137,8 @@ A walk-through: - **`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` - (spec proposal 0013, v0.10.0). `None` on every other event. + `item_count` / `concurrency` / `error_policy` / `parent_node_name`. + `None` on every other event. ## Routing errors and the completed event @@ -250,8 +251,8 @@ Two identifiers travel with every invocation: having its own `invocation_id`. `correlation_id` is the load-bearing join key in the multi-backend -scenario the charter calls out: a Langfuse trace, an OTel trace, and -a structured log all end up with the same `correlation_id` even +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 @@ -280,7 +281,7 @@ correlation: `OTelObserver` constructs a **private** `TracerProvider` from the processor you supply — it never registers globally and never reads -`get_tracer_provider()`. This is mandated by spec observability §6. +`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 diff --git a/docs/concepts/state-and-reducers.md b/docs/concepts/state-and-reducers.md index 80f5a182..95ad73b1 100644 --- a/docs/concepts/state-and-reducers.md +++ b/docs/concepts/state-and-reducers.md @@ -59,12 +59,11 @@ while not at END: current_node = next_node_for(state) ``` -This is by design. Per the spec's -[graph engine foundation proposal](https://github.com/LunarCommand/openarmature-spec/blob/main/proposals/0001-graph-engine-foundation.md), -"checkpoint/resume, per-node streaming, persistent state backends, and -human-in-the-loop interrupts" are explicit non-goals for the engine. -They're pipeline-layer utilities that compose *on top of* the graph -primitives. Keeping the engine one-job keeps it small. +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": diff --git a/docs/getting-started/provider-authoring.md b/docs/getting-started/provider-authoring.md index 9cadb64c..851a5d04 100644 --- a/docs/getting-started/provider-authoring.md +++ b/docs/getting-started/provider-authoring.md @@ -11,11 +11,11 @@ contract a Provider has to satisfy. ## What you implement -The `Provider` Protocol is two async methods (spec §5): +The `Provider` Protocol is two async methods: - `async ready() -> None` — verifies the bound model is reachable. A - successful return means the next `complete()` shouldn't raise §7 - categories that surface mismatched configuration or unloaded state. + successful return means the next `complete()` shouldn't raise + errors that surface mismatched configuration or unloaded state. - `async complete(messages, tools=None, config=None) -> Response` — performs a single completion. **Stateless** (no per-call state held across invocations), **reentrant** (safe to call concurrently), and @@ -108,8 +108,8 @@ class MyProvider: 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 (spec §6 explicit). + # 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"), @@ -136,7 +136,7 @@ def _msg_to_wire(msg: Message) -> dict[str, Any]: ## Contract checklist -When you ship a Provider, the following MUST hold (spec §3, §5, §7): +When you ship a Provider, the following must hold: **Statelessness + reentrancy.** @@ -153,15 +153,15 @@ When you ship a Provider, the following MUST hold (spec §3, §5, §7): **Boundary validation.** -- [ ] Call `validate_message_list(messages)` to enforce spec §3 - 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_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 (spec §7).** +**Error mapping.** - [ ] Network failures (connection errors, timeouts) → `ProviderUnavailable`. - [ ] HTTP 401/403 → `ProviderAuthentication`. @@ -172,13 +172,13 @@ When you ship a Provider, the following MUST hold (spec §3, §5, §7): - [ ] HTTP 503 with model-loading → `ProviderModelNotLoaded`; otherwise → `ProviderUnavailable`. - [ ] HTTP 5xx (other) → `ProviderUnavailable`. -- [ ] 200 OK that fails to parse into spec §6 shape → +- [ ] 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 (spec §6).** +**Finish reasons.** - [ ] Return one of: `"stop"`, `"length"`, `"tool_calls"`, `"content_filter"`, `"error"`. Map the wire format's finish-reason @@ -193,8 +193,7 @@ The skeleton omits things real providers usually need. Reach for `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 (spec - observability §5.5). + 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. @@ -205,9 +204,7 @@ The skeleton omits things real providers usually need. Reach for `openarmature.llm`) to populate the `retry_after` field of `ProviderRateLimit` from the response header. -When in doubt, the openarmature spec at -[`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec) is -the source of truth. The Python conformance fixtures under +The conformance fixtures under `tests/conformance/test_llm_provider.py` exercise the wire mapping -end-to-end against the spec; a custom Provider passing those fixtures is -correctly implementing the contract. +end-to-end; a custom Provider that passes those fixtures matches the +contract. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 8509a74a..99596a33 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -27,6 +27,60 @@ 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 diff --git a/src/openarmature/checkpoint/__init__.py b/src/openarmature/checkpoint/__init__.py index 7a8df98f..0793e55d 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 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..29b4e540 100644 --- a/src/openarmature/checkpoint/protocol.py +++ b/src/openarmature/checkpoint/protocol.py @@ -1,4 +1,12 @@ -"""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 completed +# events). +# - Fan-out 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 +18,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 +39,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 +59,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 +87,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 +111,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 +142,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..362f423d 100644 --- a/src/openarmature/graph/errors.py +++ b/src/openarmature/graph/errors.py @@ -1,9 +1,14 @@ +# 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 compile errors 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 +18,7 @@ class GraphError(Exception): """Base for all graph-engine errors.""" -# ===== Compile-time errors (spec §2) ===== +# ===== Compile-time errors ===== class CompileError(GraphError): @@ -63,8 +68,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 +82,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 +93,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 +104,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 +182,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 +205,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 +219,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 +237,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..d62d5ae8 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,59 @@ 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 added by pipeline-utilities §10.8. ``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 976908d1..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``. """ 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..7ce9d05c 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 + Tool typed surface) and §4 +# (validation timing). 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/openai.py b/src/openarmature/llm/providers/openai.py index eabb09ea..7534ff86 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 @@ -188,13 +191,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) @@ -462,15 +465,15 @@ def _wire_to_assistant_message(wire: dict[str, Any], *, lenient_args: bool) -> A def classify_http_error(resp: httpx.Response) -> Exception: """Map a non-200 ``httpx.Response`` from an OpenAI-shape API to - the right spec §7 error category. + the right canonical error category. - Returns the exception (does not raise) so the caller can ``raise`` - with consistent traceback context. + 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 spec §7 mapping table. + saves implementers from reimplementing the mapping table. """ status = resp.status_code try: @@ -518,7 +521,7 @@ def parse_retry_after(value: str | None) -> float | None: HTTP-date. Reusable by third-party Provider implementations that need to - surface ``Retry-After`` to spec §7's ``retry_after`` field. + surface ``Retry-After`` to ``ProviderRateLimit.retry_after``. """ if value is None: return None 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..5f1807cc 100644 --- a/src/openarmature/observability/otel/observer.py +++ b/src/openarmature/observability/otel/observer.py @@ -1,44 +1,54 @@ -"""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. +# - Per-invocation state isolation uses ``invocation_id`` per §5.1. +# - Subgraph dispatch spans implement §4.5; detached trace mode §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 +56,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. From 19cf51c22b930ae3fe1ed252726274fb4b8835b7 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 14:21:34 -0700 Subject: [PATCH 18/20] docs: Model Providers section + light/dark theme polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures Provider docs and irons out remaining theme inconsistencies after the spec-review pass. **Nav restructure.** ``Authoring a Provider`` no longer lives as a standalone page under Getting Started. New top-level section ``Model Providers`` between Concepts and Reference, with two pages: - ``model-providers/index.md`` — overview: what a Provider is, what ships (OpenAIProvider + OpenAI-compatible servers), the two-method Protocol surface, behavioural guarantees, seven error categories, a minimal direct-usage example, pointers onward. - ``model-providers/authoring.md`` — moved from ``getting-started/provider-authoring.md``; opens by pointing back to the overview rather than re-introducing concepts. ``tests/test_docs_examples.py`` now runs Python code blocks under both ``getting-started/`` AND ``model-providers/`` (the Protocol + skeleton snippets are full programs and worth drift-checking). **Dark-theme polish.** - Inline code (``--md-code-fg-color``) ``#e4c1f9`` (light lavender) to pair with the ``#9d4edd`` brand accent. - Code-inside-link ``#cd8bf4`` (mid lavender) — applies to the ``openarmature.X`` links on the API Reference index plus any other linked-code patterns. Hover left to Material's cascade. - Code-block name/identifier tokens re-scoped inside ``pre`` to ``rgba(226, 228, 233, 0.82)`` (slate body-text tone) so the inline-code lavender doesn't leak into code blocks for unclassified tokens. - Per-token highlight colours (keywords/strings/numbers etc.) reverted to Material defaults after iteration found the brand-shifted and minimalist palettes both too purple-heavy. **Light-theme polish.** - Page surface ``#f5f5f5``. - Code background ``#e9e9e9`` (subtle contrast with the page surface, no border needed). - Code-inside-link colour matches dark-theme ``#cd8bf4``. **Landing tweak.** ``View on GitHub`` button gets ``target="_blank" rel="noopener"`` so the repo opens in a new tab rather than navigating away from the docs. --- docs/index.md | 2 +- .../authoring.md} | 111 ++++++++--------- docs/model-providers/index.md | 116 ++++++++++++++++++ docs/stylesheets/extra.css | 62 +++++++++- mkdocs.yml | 4 +- tests/test_docs_examples.py | 16 +-- 6 files changed, 236 insertions(+), 75 deletions(-) rename docs/{getting-started/provider-authoring.md => model-providers/authoring.md} (63%) create mode 100644 docs/model-providers/index.md diff --git a/docs/index.md b/docs/index.md index 7333358d..76db8198 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ 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 } +[View on GitHub](https://github.com/LunarCommand/openarmature-python){ .md-button target="_blank" rel="noopener" } --- diff --git a/docs/getting-started/provider-authoring.md b/docs/model-providers/authoring.md similarity index 63% rename from docs/getting-started/provider-authoring.md rename to docs/model-providers/authoring.md index 851a5d04..0c63a0f2 100644 --- a/docs/getting-started/provider-authoring.md +++ b/docs/model-providers/authoring.md @@ -1,35 +1,22 @@ -# Authoring a custom Provider +# Authoring a Provider -`openarmature` ships an `OpenAIProvider` for any OpenAI Chat -Completions-compatible endpoint (vLLM, LM Studio, llama.cpp, the OpenAI -API itself). If you need to target a different wire format — Anthropic -Messages, Bedrock, an internal gateway, a hand-rolled inference service — -implement the `Provider` Protocol yourself. +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. -This page walks through a minimal skeleton (~60 lines of real code) and the -contract a Provider has to satisfy. - -## What you implement - -The `Provider` Protocol is two async methods: - -- `async ready() -> None` — verifies the bound model is reachable. A - successful return means the next `complete()` shouldn't raise - errors that surface mismatched configuration or unloaded state. -- `async complete(messages, tools=None, config=None) -> Response` — - performs a single completion. **Stateless** (no per-call state held - across invocations), **reentrant** (safe to call concurrently), and - **non-mutating** (`messages` MUST NOT be modified). The Provider does - not loop on tool calls and does not retry — those are the caller's and - middleware's jobs respectively. +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` (~465 lines) 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.). +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 @@ -140,16 +127,16 @@ 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. +- [ ] `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). + 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. +- [ ] `messages` passed to `complete()` MUST NOT be mutated. Build + wire bodies from copies / projections; never modify the input. **Boundary validation.** @@ -158,53 +145,57 @@ When you ship a Provider, the following must hold: 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). +- [ ] Call `validate_tools(tools)` if tools are accepted + (duplicate-name check). **Error mapping.** -- [ ] Network failures (connection errors, timeouts) → `ProviderUnavailable`. +- [ ] 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 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. +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. + `"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: +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 + `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 +- **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. +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/stylesheets/extra.css b/docs/stylesheets/extra.css index 99596a33..7daed5ec 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -97,6 +97,35 @@ --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. */ @@ -116,6 +145,14 @@ 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. */ @@ -175,7 +212,7 @@ } /* ===== Light-theme styling ===== - * Mirrors the dark-theme structure. Unified surface color ``#fbfefb`` + * 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. @@ -183,15 +220,15 @@ * 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 ``#fbfefb`` so the entire surface is one tone. + * (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: #fbfefb; + --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: #fbfefb; - --md-footer-bg-color: #fbfefb; + --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 @@ -200,11 +237,16 @@ --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: #fbfefb; + background-color: #f5f5f5; } [data-md-color-scheme="default"] .md-header { @@ -244,6 +286,14 @@ 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, diff --git a/mkdocs.yml b/mkdocs.yml index 08bbc098..d4a9965a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -91,7 +91,6 @@ nav: - OpenArmature: index.md - Getting Started: - Quickstart: getting-started/index.md - - Authoring a Provider: getting-started/provider-authoring.md - Concepts: - concepts/index.md - State and reducers: concepts/state-and-reducers.md @@ -100,6 +99,9 @@ nav: - 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 diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index bd857cc5..e02d5829 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -24,12 +24,14 @@ @pytest.mark.parametrize("example", PYTHON_EXAMPLES, ids=str) def test_docs_example(example: CodeExample, eval_example: EvalExample) -> None: - # Only ``getting-started/`` snippets are full runnable programs. - # 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 Quickstart + the Provider skeleton + the auto-generated - # mkdocstrings reference. - if "getting-started" not in str(example.path): + # 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) From 7b0490e26dbe4f95cf1ca08e6b6438617f67f879 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 15:02:26 -0700 Subject: [PATCH 19/20] docs: tighten spec breadcrumbs from spec review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the breadcrumb fixes spec flagged in coord file 08: - llm/messages.py: swap §3/§4 framing — §3 is Message+ToolCall+ validation timing, §4 is Tool definition (was reversed). - graph/events.py: reframe NodeEvent's `checkpoint_saved` phase as the Python shape for §10.8 save events (§10.8 SHOULDs an event emit but leaves the shape implementation-defined). - graph/errors.py: clarify fan-out-specific error categories cover both compile- and runtime-time, not just compile. - checkpoint/__init__.py + protocol.py: add fan-out nodes to §10.3 save sources (the three sources are outermost-graph + subgraph-internal + fan-out, not two). - observability/otel/observer.py: reframe §5.1 cite (the keying is impl choice; §5.1 governs the attribute) and §4.5 cite (§4.5 is span names only; hierarchy/timing live in §4.1/§4.3). Source-comment-only — no behavior change, no public API change. --- src/openarmature/checkpoint/__init__.py | 2 +- src/openarmature/checkpoint/protocol.py | 9 +++++---- src/openarmature/graph/errors.py | 3 ++- src/openarmature/graph/events.py | 5 +++-- src/openarmature/llm/messages.py | 6 +++--- src/openarmature/observability/otel/observer.py | 8 ++++++-- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/openarmature/checkpoint/__init__.py b/src/openarmature/checkpoint/__init__.py index 0793e55d..3acc363d 100644 --- a/src/openarmature/checkpoint/__init__.py +++ b/src/openarmature/checkpoint/__init__.py @@ -3,7 +3,7 @@ # - 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 nodes per §10.3. +# internal + fan-out nodes per §10.3. # - Resume via ``invoke(resume_invocation=...)`` restores per §10.4. """openarmature.checkpoint — checkpointing capability. diff --git a/src/openarmature/checkpoint/protocol.py b/src/openarmature/checkpoint/protocol.py index 29b4e540..6ca11dd9 100644 --- a/src/openarmature/checkpoint/protocol.py +++ b/src/openarmature/checkpoint/protocol.py @@ -1,10 +1,11 @@ # 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 completed -# events). -# - Fan-out internal events do NOT save in v1 per §10.7 (atomic-restart -# contract); proposal 0009 specifies the future per-instance variant. +# - 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. diff --git a/src/openarmature/graph/errors.py b/src/openarmature/graph/errors.py index 362f423d..c9f9b99d 100644 --- a/src/openarmature/graph/errors.py +++ b/src/openarmature/graph/errors.py @@ -1,7 +1,8 @@ # 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 compile errors mirror pipeline-utilities §9. +# per §4. Fan-out-specific error categories (both compile and runtime) +# mirror pipeline-utilities §9. """Errors raised by the graph engine. diff --git a/src/openarmature/graph/events.py b/src/openarmature/graph/events.py index d62d5ae8..599cb2b6 100644 --- a/src/openarmature/graph/events.py +++ b/src/openarmature/graph/events.py @@ -70,8 +70,9 @@ class FanOutEventConfig: # Spec: realizes graph-engine §6 NodeEvent (started/completed pair # model from proposal 0005, v0.6.0). The ``checkpoint_saved`` phase -# is added by pipeline-utilities §10.8. ``fan_out_config`` is the -# observability §5.4 / proposal 0013 (v0.10.0) addition. +# 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. diff --git a/src/openarmature/llm/messages.py b/src/openarmature/llm/messages.py index 7ce9d05c..fab9de70 100644 --- a/src/openarmature/llm/messages.py +++ b/src/openarmature/llm/messages.py @@ -1,6 +1,6 @@ -# Spec: realizes llm-provider §3 (Message + Tool typed surface) and §4 -# (validation timing). Tool-call ids preserved verbatim — no rewrite -# or normalization, per spec §3. +# 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. diff --git a/src/openarmature/observability/otel/observer.py b/src/openarmature/observability/otel/observer.py index 5f1807cc..959e110c 100644 --- a/src/openarmature/observability/otel/observer.py +++ b/src/openarmature/observability/otel/observer.py @@ -1,8 +1,12 @@ # Spec mapping (observability): # - Observer-driven span lifecycle realizes §6 (RECOMMENDED path). # - Span status comes from §4.2 error-category mapping. -# - Per-invocation state isolation uses ``invocation_id`` per §5.1. -# - Subgraph dispatch spans implement §4.5; detached trace mode §4.4. +# - 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 From babb4185a60e1f3cc79db9840bee56b9b58113ec Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 12 May 2026 15:07:33 -0700 Subject: [PATCH 20/20] docs: PR #32 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two CoPilot review threads on PR #32: - tests/test_examples_smoke.py: smoke test was import-only via runpy and never called build_graph(). Tightened to invoke the factory after the module load so the convention the demo docstrings describe is actually exercised — graph-compile failures now surface in CI, not just import errors. - llm/providers/openai.py: tighten classify_http_error's return annotation from Exception to LlmProviderError. Every branch already returns an LlmProviderError subclass; Exception was historical from when the helper was private. Helps typed raise callers and improves the rendered API reference. --- src/openarmature/llm/providers/openai.py | 3 ++- tests/test_examples_smoke.py | 22 +++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/openarmature/llm/providers/openai.py b/src/openarmature/llm/providers/openai.py index 7534ff86..f720990d 100644 --- a/src/openarmature/llm/providers/openai.py +++ b/src/openarmature/llm/providers/openai.py @@ -58,6 +58,7 @@ ) from ..errors import ( + LlmProviderError, ProviderAuthentication, ProviderInvalidModel, ProviderInvalidRequest, @@ -463,7 +464,7 @@ def _wire_to_assistant_message(wire: dict[str, Any], *, lenient_args: bool) -> A ) -def classify_http_error(resp: httpx.Response) -> Exception: +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. diff --git a/tests/test_examples_smoke.py b/tests/test_examples_smoke.py index 57eefa83..785e7767 100644 --- a/tests/test_examples_smoke.py +++ b/tests/test_examples_smoke.py @@ -1,18 +1,23 @@ -"""Smoke test: load each example's ``main.py`` to verify it imports -cleanly. +"""Smoke test: load each example's ``main.py`` and invoke its +``build_graph()`` factory. -We don't actually run the demos — they hit a local OpenAI-compatible -LLM endpoint that isn't available in CI. But loading the module -catches: +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). +- 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 @@ -37,4 +42,7 @@ def test_example_loads(demo: str) -> None: main_py = EXAMPLES_DIR / demo / "main.py" assert main_py.exists(), f"missing: {main_py}" - runpy.run_path(str(main_py), run_name="__not_main__") + 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()