From 480acdf8f800848b8f144701f64fc290ddbf7039 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:00:41 +0200 Subject: [PATCH 01/15] chore(phase-0): scaffold pipelex-sdk package & tooling Greenfield scaffold for the Python client of the Pipelex hosted API, mirroring mthds-python's tooling so every later phase runs on a green gate. - pyproject.toml: PyPI `pipelex-sdk` / import `pipelex_sdk`, requires-python >=3.10,<3.15, hatchling, runtime deps (mthds>=0.5.0, pydantic, httpx, typing-extensions, backports.strenum) and the full ruff/pyright/mypy/ pylint/pytest config. [tool.uv.sources] resolves mthds from ../mthds-python for dev; the published wheel depends on mthds>=0.5.0 from PyPI. - Makefile: install/lock/build/test/agent-test/format/lint/pyright/mypy/ pylint and the c/cc/check/agent-check aggregates. - pipelex_sdk/_compat.py: local StrEnum/Self shim (no cross-package private import). - Boilerplate: README, CHANGELOG (Unreleased), .gitignore, CLAUDE.md (overview + standards), docs/architecture.md skeleton, smoke test. Kickoff decisions settled: package = pipelex-sdk/pipelex_sdk; client built by inheritance (PipelexAPIClient(MthdsAPIClient)); credentials Pipelex-primary with mthds fallback. Gate green: make install && make check && make agent-test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013cPeza9ezw38JFCi3uXP4m --- .gitignore | 14 + CHANGELOG.md | 9 + CLAUDE.md | 109 ++++++ Makefile | 309 ++++++++++++++++ README.md | 47 +++ docs/architecture.md | 60 +++ pipelex_sdk/__init__.py | 0 pipelex_sdk/_compat.py | 10 + pyproject.toml | 352 ++++++++++++++++++ tests/unit/test_smoke.py | 6 + uv.lock | 765 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 1681 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/architecture.md create mode 100644 pipelex_sdk/__init__.py create mode 100644 pipelex_sdk/_compat.py create mode 100644 pyproject.toml create mode 100644 tests/unit/test_smoke.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1db7a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +.env +.pipelex/ +.DS_Store +.coverage +.cache/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..669fe1f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to `pipelex-sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial repository scaffold: packaging (`pyproject.toml`), tooling (`Makefile`, ruff/pyright/mypy/pylint config mirroring `mthds-python`), and the empty `pipelex_sdk` package. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e1389c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# pipelex-sdk-python + +This file guides Claude Code when working in this repo. It is self-contained: the repo overview below, then the Python coding standards (mirroring `../mthds-python/CLAUDE.md`, the relevant standard for this package). The workspace-root `CLAUDE.md` and `.claude/rules/python-standards.md` also apply. + +## What this repo is + +`pipelex-sdk` (import package `pipelex_sdk`) — the Python client for the Pipelex hosted API. It is the Python counterpart of the TypeScript `@pipelex/sdk` (`PipelexApiClient`), built on `mthds` (the `mthds-python` package) exactly as `@pipelex/sdk` is built on the `mthds` npm package. + +It is the **hosted superset**: the five normative MTHDS Protocol routes (inherited from `mthds`) **plus** the durable run lifecycle **plus** the Pipelex product surface (methods, organizations, billing, API keys, onboarding, storage, run records). See `docs/architecture.md`. + +## Architecture invariants (do not violate) + +- **One-way dependency: `pipelex-sdk → mthds`.** This package depends on `mthds` and never the reverse. +- **Inheritance, not re-implementation.** `class PipelexAPIClient(MthdsAPIClient)`. Reuse the base transport (`_send`, `_url`), body-builders, the reusable protocol methods, `runner_type`, and the async context-manager. Add lifecycle/product/health on top. The base's single-underscore transport methods are treated as a documented **protected extension surface** — do not rename or fork them. +- **Brand boundary (MTHDS vs Pipelex).** MTHDS = the open standard's brand; Pipelex = the runtime/product brand. Protocol routes and their models belong to `mthds` and keep neutral names; Pipelex-specific surfaces (lifecycle, product routes, implementation envelopes) live here. Name by which brand owns the concept. +- **Credentials.** Resolve `PIPELEX_API_KEY` / `PIPELEX_API_URL` first, then fall back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_URL`, `~/.mthds/config`). Token is **optional** (anonymous allowed). Default base URL `https://api.pipelex.com`. +- **Async-only.** httpx `AsyncClient`, `async def` throughout. No sync facade in v0.1. +- **No barrel.** `__init__.py` files stay empty — no re-exports, no docstrings. Import via full paths (`from pipelex_sdk.client import PipelexAPIClient`). + +## Workflow + +- Use Make targets: `make install`, `make agent-check`, `make agent-test`, `make check`. +- Always run `make agent-check` and `make agent-test` before considering a change done or pushing. +- Test-first where practical. `pytest-mock` only (never `unittest.mock`). One `TestClass` per test module. No `__init__.py` in test directories. +- Document every iteration: update `docs/` and `CHANGELOG.md` alongside code. +- No hardcoded counts in code/docs/commits. Pre-1.0 breaking changes → minor version bump. + +--- + +# Python Coding Best Practices + +## Python Version Compatibility + +- Target Python 3.10+. Never use features introduced after Python 3.10 without a compatibility fallback. +- Common pitfalls: + - `datetime.UTC` was added in Python 3.11. Use `datetime.timezone.utc` instead. + - `StrEnum` was added in Python 3.11. Import it from the local `pipelex_sdk._compat` shim. + - `type` statement (PEP 695) was added in Python 3.12. Use `TypeAlias` from `typing` instead. + - `ExceptionGroup` / `except*` was added in Python 3.11. Avoid unless using the `exceptiongroup` backport. + +## Variables, Loops and Indexes + +- Variable names should have a minimum length of 3 characters. No exceptions: name your `for` loop indexes like `index_item`, your exceptions `exc` or more specific like `validation_error` when there are several layers of exceptions, and use `for key, value in ...` for key/value pairs. +- When looping on the keys of a dict, use `for key in the_dict` rather than `for key in the_dict.keys()`. +- Avoid inline for loops, unless it's ultra-simple and holds on one line. +- If you have a variable that will get its value differently through different code paths, declare it first with a type, e.g. `result: str` but DO NOT give it a default value like `result: str = ""` unless it's really justified. We want the variable to be unbound until all paths are covered, and the linters will help us avoid bugs this way. + +## Enums + +- When defining enums related to string values, always inherit from `StrEnum` (from `pipelex_sdk._compat`). +- When you need the enum value as a string, don't use `str(enum_var)` or `enum_var.value`, just use `enum_var` itself — that is the point of using StrEnum. +- Never test equality to an enum value: use match/case, even to single out 1 case out of 10 cases. To avoid heavy match/case code in awkward places, add `@property` methods to the enum class such as `is_foobar()`. This prevents bugs: when new enum values are added the linter will complain about non-exhaustive matches. Use the `|` operator to group cases. +- Match/case constructs over enums should always be exhaustive. NEVER add a default `case _: ...`. + +## Optionals + +- Don't write things like `a = b if b else c`, write `a = b or c` instead. + +## Imports + +- Import all necessary libraries at the top of the file. Do not import inside functions/classes unless a `# noqa` is genuinely required. +- Do not bother ordering or removing unused imports — let Ruff handle it (`make fui`). +- `if TYPE_CHECKING:` blocks must be the **last** block in the imports section. +- No re-exports in `__init__.py`. Always use direct full-path imports. + +## Typing + +- Every function parameter and return must be typed. Type all fields and non-obvious variables. +- Use lowercase generics: `dict[]`, `list[]`, `tuple[]`. Use `Field(default_factory=...)` for mutable defaults. +- Use `# pyright: ignore[specificError]` / `# type: ignore` only as a last resort; prefer `cast()` or a new typed variable. + +### BaseModel / Pydantic Standards + +- Use `BaseModel` and respect Pydantic v2 standards. Use `ConfigDict` when needed (e.g. `model_config = ConfigDict(extra="forbid", strict=True)`). Wire models that must tolerate unknown server fields use `extra="allow"`. +- Keep models focused and single-purpose. For list fields with non-string items, use a typed `default_factory`. + +## Error Handling + +- Catch exceptions where you can add useful context. Use specific exceptions. Convert third-party exceptions to custom ones (except in pydantic validators, where `ValueError`/`TypeError` are fine). +- NEVER catch the generic `Exception` except at a top-level entry point (with a one-line comment naming why). +- Always `raise NewError(msg) from exc`. Write the message into a variable before raising. +- Put custom error classes in `exceptions.py` / `errors.py` modules (this package uses `pipelex_sdk/errors.py`). + +```python +try: + await client.start(...) +except RunLifecycleUnavailableError as exc: + msg = "This runner does not support the durable run lifecycle" + raise SomeError(msg) from exc +``` + +## Writing Tests + +- NEVER use `unittest.mock`. Always use pytest-mock: `from pytest_mock import MockerFixture`. +- NEVER put more than one TestClass into a test module. +- Name test files `test_*.py`. Place them under `tests/unit/`, `tests/integration/`, or `tests/e2e/`. No `__init__.py` in test directories. +- Fixtures go in `conftest.py`; test data constants go in `test_data.py` grouped in classes. +- Use strong asserts (test value, not just type/presence). Use `parametrize` for multiple cases. Test success and failure paths. Mock at the httpx boundary. + +## Test-Driven Development + +1. Write a test first. +2. Write the minimum code to pass it. +3. Run linting and type checking (`make agent-check`). +4. Validate tests (`make agent-test`). + +## Post-Coding Checklist + +After finishing any change, run `make agent-check` && `make agent-test`. Do not consider a task done until both pass. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4ca82c --- /dev/null +++ b/Makefile @@ -0,0 +1,309 @@ +SHELL := /bin/bash +.SHELLFLAGS := -o pipefail -c + +ifeq ($(wildcard .env),.env) +include .env +export +endif +VIRTUAL_ENV := $(CURDIR)/.venv +PROJECT_NAME := $(shell grep '^name = ' pyproject.toml | sed -E 's/name = "(.*)"/\1/') + +# The "?" is used to make the variable optional, so that it can be overridden by the user. +PYTHON_VERSION ?= 3.13 + +VENV_PYTHON := "$(VIRTUAL_ENV)/bin/python" +VENV_PYTEST := "$(VIRTUAL_ENV)/bin/pytest" +VENV_RUFF := "$(VIRTUAL_ENV)/bin/ruff" +VENV_PYRIGHT := "$(VIRTUAL_ENV)/bin/pyright" +VENV_MYPY := "$(VIRTUAL_ENV)/bin/mypy" +VENV_PYLINT := "$(VIRTUAL_ENV)/bin/pylint" + +UV_MIN_VERSION = $(shell grep -m1 'required-version' pyproject.toml | sed -E 's/.*= *"([^<>=, ]+).*/\1/') + + +define PRINT_TITLE + $(eval PROJECT_PART := [$(PROJECT_NAME)]) + $(eval TARGET_PART := ($@)) + $(eval MESSAGE_PART := $(1)) + $(if $(MESSAGE_PART),\ + $(eval FULL_TITLE := === $(PROJECT_PART) ===== $(TARGET_PART) ====== $(MESSAGE_PART) ),\ + $(eval FULL_TITLE := === $(PROJECT_PART) ===== $(TARGET_PART) ====== )\ + ) + $(eval TITLE_LENGTH := $(shell echo -n "$(FULL_TITLE)" | wc -c | tr -d ' ')) + $(eval PADDING_LENGTH := $(shell echo $$((126 - $(TITLE_LENGTH))))) + $(eval PADDING := $(shell printf '%*s' $(PADDING_LENGTH) '' | tr ' ' '=')) + $(eval PADDED_TITLE := $(FULL_TITLE)$(PADDING)) + @echo "" + @echo "$(PADDED_TITLE)" +endef + +define HELP +Manage $(PROJECT_NAME) located in $(CURDIR). +Usage: + +make env - Create python virtual env +make lock - Refresh uv.lock without updating anything +make install - Create local virtualenv & install all dependencies +make update - Upgrade dependencies via uv +make build - Build the wheels + +make test - Run unit tests +make test-with-prints - Run unit tests with prints +make t - Shorthand -> test +make tp - Shorthand -> test-with-prints +make gha-tests - Run tests for GitHub Actions (exit on first failure, quiet) +make agent-test - Run unit tests quietly (only prints on failure) + +make format - format with ruff format +make lint - lint with ruff check +make pyright - Check types with pyright +make mypy - Check types with mypy +make pylint - Lint with pylint + +make cleanenv - Remove virtual env and lock files +make cleanderived - Remove extraneous compiled files, caches, logs, etc. +make cleanall - Remove all -> cleanenv + cleanderived + +make merge-check-ruff-lint - Run ruff merge check without updating files +make merge-check-ruff-format - Run ruff merge check without updating files +make merge-check-mypy - Run mypy merge check without updating files +make merge-check-pyright - Run pyright merge check without updating files + +make check-unused-imports - Check for unused imports without fixing +make fix-unused-imports - Fix unused imports with ruff +make fui - Shorthand -> fix-unused-imports +make check-TODOs - Check for TODOs + +make check - Shorthand -> cleanderived format lint pyright mypy check-unused-imports pylint +make agent-check - Shorthand -> fui format lint pyright mypy +make c - Shorthand -> format lint pyright mypy +make cc - Shorthand -> cleanderived check +make li - Shorthand -> lock install + +endef +export HELP + +.PHONY: all help env env-verbose check-uv check-uv-verbose lock install update build test test-with-prints t tp gha-tests agent-test format lint pyright mypy pylint merge-check-ruff-format merge-check-ruff-lint merge-check-pyright merge-check-mypy merge-check-pylint check-unused-imports fix-unused-imports check-TODOs cleanderived cleanenv cleanall c cc li agent-check + +all help: + @echo "$$HELP" + + +########################################################################################## +### SETUP +########################################################################################## + +# Quiet check-uv: only shows output if uv is missing (needs install) +check-uv: + @command -v uv >/dev/null 2>&1 || { \ + echo ""; \ + echo "=== [$(PROJECT_NAME)] ===== (check-uv) ====== Ensuring uv ≥ $(UV_MIN_VERSION) =========="; \ + echo "uv not found – installing latest …"; \ + curl -LsSf https://astral.sh/uv/install.sh | sh; \ + } + @uv self update >/dev/null 2>&1 || true + +# Verbose check-uv: always shows output (for setup commands) +check-uv-verbose: + $(call PRINT_TITLE,"Ensuring uv ≥ $(UV_MIN_VERSION)") + @command -v uv >/dev/null 2>&1 || { \ + echo "uv not found – installing latest …"; \ + curl -LsSf https://astral.sh/uv/install.sh | sh; \ + } + @uv self update >/dev/null 2>&1 || true + +# Quiet env: only shows output if venv needs to be created +env: check-uv + @if [ ! -d "$(VIRTUAL_ENV)" ]; then \ + echo ""; \ + echo "=== [$(PROJECT_NAME)] ===== (env) ====== Creating virtual environment ================="; \ + echo "Creating Python virtual env in \`${VIRTUAL_ENV}\`"; \ + uv venv "$(VIRTUAL_ENV)" --python $(PYTHON_VERSION); \ + echo "Using Python: $$($(VENV_PYTHON) --version) from $$(readlink $(VENV_PYTHON) 2>/dev/null || echo $(VENV_PYTHON))"; \ + fi + +# Verbose env: always shows output (for setup commands like install, lock, update) +env-verbose: check-uv-verbose + $(call PRINT_TITLE,"Creating virtual environment") + @if [ ! -d "$(VIRTUAL_ENV)" ]; then \ + echo "Creating Python virtual env in \`${VIRTUAL_ENV}\`"; \ + uv venv "$(VIRTUAL_ENV)" --python $(PYTHON_VERSION); \ + else \ + echo "Python virtual env already exists in \`${VIRTUAL_ENV}\`"; \ + fi + @echo "Using Python: $$($(VENV_PYTHON) --version) from $$(readlink $(VENV_PYTHON) 2>/dev/null || echo $(VENV_PYTHON))" + +install: env-verbose + $(call PRINT_TITLE,"Installing dependencies") + @. "$(VIRTUAL_ENV)/bin/activate" && \ + uv sync --all-extras && \ + echo "Installed $(PROJECT_NAME) dependencies in ${VIRTUAL_ENV} with all extras."; + +lock: env + $(call PRINT_TITLE,"Resolving dependencies without update") + @uv lock && \ + echo uv lock without update; + +update: env + $(call PRINT_TITLE,"Updating all dependencies") + @uv lock --upgrade && \ + uv sync --all-extras && \ + echo "Updated dependencies in ${VIRTUAL_ENV}"; + + +build: env + $(call PRINT_TITLE,"Building the wheels") + @uv build + +########################################################################################## +### CLEANING +########################################################################################## + +cleanderived: + $(call PRINT_TITLE,"Erasing derived files and directories") + @find . -name '.coverage' -delete && \ + find . -wholename '**/*.pyc' -delete && \ + find . -type d -wholename '__pycache__' -exec rm -rf {} + && \ + find . -type d -wholename './.cache' -exec rm -rf {} + && \ + find . -type d -wholename './.mypy_cache' -exec rm -rf {} + && \ + find . -type d -wholename './.ruff_cache' -exec rm -rf {} + && \ + find . -type d -name '.pytest_cache' -exec rm -rf {} + && \ + find . -type d -wholename './logs/*.log' -exec rm -rf {} + && \ + find . -type d -wholename './.reports/*' -exec rm -rf {} + && \ + echo "Cleaned up derived files and directories"; + +cleanenv: + $(call PRINT_TITLE,"Erasing virtual environment") + find . -name 'uv.lock' -delete && \ + rm -rf "$(VIRTUAL_ENV)" && \ + echo "Cleaned up virtual env and dependency lock files"; + +cleanall: cleanderived cleanenv + @echo "Cleaned up all derived files and directories"; + +########################################################################################## +### TESTING +########################################################################################## + +test: env + $(call PRINT_TITLE,"Unit testing") + @if [ -n "$(TEST)" ]; then \ + $(VENV_PYTEST) -o log_cli=true -o log_level=WARNING -k "$(TEST)" $(if $(filter 1,$(VERBOSE)),-v,$(if $(filter 2,$(VERBOSE)),-vv,$(if $(filter 3,$(VERBOSE)),-vvv,))); \ + else \ + $(VENV_PYTEST) -o log_cli=true -o log_level=WARNING $(if $(filter 1,$(VERBOSE)),-v,$(if $(filter 2,$(VERBOSE)),-vv,$(if $(filter 3,$(VERBOSE)),-vvv,))); \ + fi + +test-with-prints: env + $(call PRINT_TITLE,"Unit testing with prints") + @if [ -n "$(TEST)" ]; then \ + $(VENV_PYTEST) -s -k "$(TEST)" $(if $(filter 1,$(VERBOSE)),-v,$(if $(filter 2,$(VERBOSE)),-vv,$(if $(filter 3,$(VERBOSE)),-vvv,))); \ + else \ + $(VENV_PYTEST) -s $(if $(filter 1,$(VERBOSE)),-v,$(if $(filter 2,$(VERBOSE)),-vv,$(if $(filter 3,$(VERBOSE)),-vvv,))); \ + fi + +t: test + @echo "> done: t = test" + +tp: test-with-prints + @echo "> done: tp = test-with-prints" + +gha-tests: env + $(call PRINT_TITLE,"Unit testing for GitHub Actions") + $(VENV_PYTEST) --exitfirst --quiet + +agent-test: env + @echo "• Running unit tests..." + @tmpfile=$$(mktemp); \ + $(VENV_PYTEST) -o log_level=WARNING --tb=short -q > "$$tmpfile" 2>&1; \ + exit_code=$$?; \ + if [ $$exit_code -ne 0 ]; then cat "$$tmpfile"; fi; \ + rm -f "$$tmpfile"; \ + if [ $$exit_code -eq 0 ]; then echo "• All tests passed."; fi; \ + exit $$exit_code + +########################################################################################## +### LINTING +########################################################################################## + +format: env + $(call PRINT_TITLE,"Formatting with ruff") + $(VENV_RUFF) format . --config pyproject.toml + +lint: env + $(call PRINT_TITLE,"Linting with ruff") + $(VENV_RUFF) check . --fix --config pyproject.toml + +pyright: env + $(call PRINT_TITLE,"Typechecking with pyright") + $(VENV_PYRIGHT) --pythonpath $(VENV_PYTHON) --project pyproject.toml + +mypy: env + $(call PRINT_TITLE,"Typechecking with mypy") + $(VENV_MYPY) --config-file pyproject.toml + +pylint: env + $(call PRINT_TITLE,"Linting with pylint") + $(VENV_PYLINT) --rcfile pyproject.toml pipelex_sdk tests + + +########################################################################################## +### MERGE CHECKS +########################################################################################## + +merge-check-ruff-format: env + $(call PRINT_TITLE,"Formatting with ruff") + $(VENV_RUFF) format --check . --config pyproject.toml + +merge-check-ruff-lint: env check-unused-imports + $(call PRINT_TITLE,"Linting with ruff without fixing files") + $(VENV_RUFF) check . --config pyproject.toml + +merge-check-pyright: env + $(call PRINT_TITLE,"Typechecking with pyright") + $(VENV_PYRIGHT) --pythonpath $(VENV_PYTHON) --project pyproject.toml + +merge-check-mypy: env + $(call PRINT_TITLE,"Typechecking with mypy") + $(VENV_MYPY) --config-file pyproject.toml + +merge-check-pylint: env + $(call PRINT_TITLE,"Linting with pylint") + $(VENV_PYLINT) --rcfile pyproject.toml pipelex_sdk tests + +########################################################################################## +### MISCELLANEOUS +########################################################################################## + +check-unused-imports: env + $(call PRINT_TITLE,"Checking for unused imports without fixing") + $(VENV_RUFF) check --select=F401 --no-fix . + +fix-unused-imports: env + $(call PRINT_TITLE,"Fixing unused imports") + $(VENV_RUFF) check --select=F401 --fix . + +fui: fix-unused-imports + @echo "> done: fui = fix-unused-imports" + +check-TODOs: env + $(call PRINT_TITLE,"Checking for TODOs") + @$(VENV_RUFF) check --select=TD -v . + +########################################################################################## +### SHORTHANDS +########################################################################################## + +c: format lint pyright mypy + @echo "> done: c = check" + +cc: cleanderived c + @echo "> done: cc = cleanderived format lint pyright mypy" + +check: cc check-unused-imports pylint + @echo "> done: check" + +agent-check: fix-unused-imports format lint pyright mypy + @echo "> done: agent-check" + +li: lock install + @echo "> done: lock install" diff --git a/README.md b/README.md new file mode 100644 index 0000000..593278c --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# pipelex-sdk + +The Python client for the [Pipelex](https://www.pipelex.com) hosted API. + +`pipelex-sdk` is the Python counterpart of [`@pipelex/sdk`](https://www.npmjs.com/package/@pipelex/sdk), exactly as [`mthds`](https://pypi.org/project/mthds/) (the `mthds-python` package) is the Python counterpart of the `mthds` npm package. It is the **hosted superset**: the five normative MTHDS Protocol routes (inherited from `mthds`) **plus** the durable run lifecycle **plus** the Pipelex product surface (methods, organizations, billing, API keys, onboarding, storage, run records). + +One-way dependency: `pipelex-sdk → mthds`. + +## Status + +Early development. The public surface is being built phase by phase; see `docs/architecture.md`. + +## Install + +```bash +pip install pipelex-sdk +``` + +## Usage + +The client is async-only (httpx `AsyncClient` under the hood) and constructs from the environment: + +```python +from pipelex_sdk.client import PipelexAPIClient + +async with PipelexAPIClient() as client: + ... +``` + +Credentials resolve from `PIPELEX_API_KEY` / `PIPELEX_API_URL`, falling back to `MTHDS_API_KEY` / `MTHDS_API_URL` (and `~/.mthds/config`). A token is optional — anonymous access works against the protocol routes; product routes require authentication. The default base URL is `https://api.pipelex.com`. + +There is no barrel import: import from the full module path (e.g. `from pipelex_sdk.client import PipelexAPIClient`). The quickstart and the full list of public import paths will be documented here as the surface lands. + +## Development + +```bash +make install # create the venv and install all extras (resolves `mthds` from ../mthds-python) +make agent-check # fix-imports + format + lint + pyright + mypy +make agent-test # run the test suite quietly (prints only on failure) +make check # full gate: agent-check aggregate + unused-imports + pylint +``` + +See `CLAUDE.md` for the coding standards and `docs/architecture.md` for the design. + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5fb8156 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,60 @@ +# pipelex-sdk architecture + +This document grows phase by phase as the SDK is built. It is the design reference for the package. + +## What this is + +`pipelex-sdk` (import package `pipelex_sdk`) is the Python client for the Pipelex hosted API. It is the Python counterpart of the TypeScript `@pipelex/sdk` (`PipelexApiClient`), built on top of `mthds` (the `mthds-python` package) exactly as `@pipelex/sdk` is built on the `mthds` npm package. + +It is the **hosted superset** of the MTHDS Protocol: + +- the five normative MTHDS Protocol routes — `POST /execute`, `POST /start`, `POST /validate`, `GET /models`, `GET /version` — inherited from the protocol base; +- **plus** the durable run lifecycle (`get_run_status`, `get_run_result`, `wait_for_result`, `start_and_wait`); +- **plus** the Pipelex product surface (methods catalog, organizations, billing, API keys, onboarding, storage, run records). + +## Dependency direction + +One-way: `pipelex-sdk → mthds`. The SDK depends on `mthds` for the protocol/transport base and never the reverse. This mirrors the TypeScript `@pipelex/sdk → mthds` edge. + +The client is built by **inheritance**: + +``` +mthds.runners.api.client.MthdsAPIClient (protocol-only base: transport, body-builders, the protocol routes) + └── pipelex_sdk.client.PipelexAPIClient (adds lifecycle + product + health on top) +``` + +`PipelexAPIClient` reuses the base transport (`_send`, `_url`), the request-body builders, the reusable protocol methods, `runner_type`, and the async context-manager; it adds the lifecycle, product, and health surfaces plus a richer error/transport layer. + +## Brand boundary (MTHDS vs Pipelex) + +MTHDS is the brand of the open standard (the language, the protocol). Pipelex is the brand of the hosted runtime/product. Artifacts that belong to the standard keep neutral, un-prefixed names; Pipelex branding is reserved for genuinely runtime/product-specific surfaces (the durable run lifecycle, the product routes, implementation envelopes). The five protocol routes and their models stay in `mthds`; everything Pipelex-specific lives here. + +## Credentials & configuration + +Resolved at construction time: + +- `PIPELEX_API_KEY` / `PIPELEX_API_URL` first (brand + JS parity); +- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_URL`, `~/.mthds/config`) as a secondary source. + +A token is **optional** (anonymous access is allowed; protocol routes work against anonymous bare runners, product routes return `401`). The default base URL is `https://api.pipelex.com`. The base URL is validated host-only (no path/query/fragment/embedded credentials; http/https only). + +## Conventions + +- **Async-only** — httpx `AsyncClient`, `async def` throughout. No sync facade in v0.1. +- **No barrel** — package `__init__.py` files stay empty; consumers import via full paths (`from pipelex_sdk.client import PipelexAPIClient`). The public import paths are documented in the README. +- **Wire format** — snake_case JSON fields on Pydantic v2 models. + +## Error regimes + +(To be detailed in Phase 1.) Two regimes, ported from the TS SDK: + +- **Product routes** raise a typed `ApiResponseError` carrying the RFC 9457 `.code` discriminant — consumers branch on `err.code` (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`), never on the HTTP status. +- **Transport failures** (DNS/connect/TLS/timeout) raise `ApiUnreachableError`. +- **Inherited protocol routes** keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. + +## Out of scope for v0.1 + +- `/v1/build/*` helpers (the TS clients carry them; recorded as a conscious deferral). +- Organization *switch* (a WorkOS session operation, not a `/v1` route). +- A `~/.pipelex/config` file reader (env-only for now, matching the JS SDK). +- A synchronous client facade. diff --git a/pipelex_sdk/__init__.py b/pipelex_sdk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pipelex_sdk/_compat.py b/pipelex_sdk/_compat.py new file mode 100644 index 0000000..f75df9f --- /dev/null +++ b/pipelex_sdk/_compat.py @@ -0,0 +1,10 @@ +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum + from typing import Self +else: + from backports.strenum import StrEnum # type: ignore[import-not-found, no-redef] + from typing_extensions import Self # type: ignore[assignment] + +__all__ = ["Self", "StrEnum"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3ac1870 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,352 @@ +[project] +name = "pipelex-sdk" +version = "0.1.0" +description = "The Python client for the Pipelex hosted API — the MTHDS Protocol surface plus the durable run lifecycle and the Pipelex product surface, built on the `mthds` protocol base." +authors = [{ name = "Evotis S.A.S.", email = "oss@pipelex.com" }] +maintainers = [{ name = "Pipelex staff", email = "oss@pipelex.com" }] +license = "MIT" +readme = "README.md" +requires-python = ">=3.10,<3.15" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", +] + +dependencies = [ + "mthds>=0.5.0", + "pydantic>=2.10.6,<3.0.0", + "backports.strenum>=1.3.0 ; python_version < '3.11'", + "typing-extensions>=4.0.0", + "httpx>=0.23.0,<1.0.0", +] + +[project.optional-dependencies] +dev = [ + "mypy==1.19.1", + "pyright==1.1.408", + "pylint==4.0.4", + "ruff==0.14.13", + "pytest>=8.0.0,<9.0.0", + "pytest-mock>=3.12.0,<4.0.0", + "pytest-sugar>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.urls] +Homepage = "https://www.pipelex.com" +Repository = "https://github.com/Pipelex/pipelex-sdk-python" +Documentation = "https://github.com/Pipelex/pipelex-sdk-python" +Changelog = "https://github.com/Pipelex/pipelex-sdk-python/blob/main/CHANGELOG.md" + +# For local development, resolve `mthds` from the sibling workspace checkout. +# uv ignores [tool.uv.sources] when building/publishing the wheel, so the +# published package depends on `mthds>=0.5.0` from PyPI. +[tool.uv.sources] +mthds = { path = "../mthds-python", editable = true } + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.mypy] +check_untyped_defs = true +exclude = "^.*\\.venv/.*$" +mypy_path = "." +packages = ["pipelex_sdk", "tests"] +plugins = ["pydantic.mypy"] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true + +[[tool.mypy.overrides]] +ignore_missing_imports = true +module = [ + "backports.strenum", +] + +[tool.pyright] +pythonVersion = "3.11" +include = ["pipelex_sdk", "tests"] +exclude = ["**/__pycache__", ".venv", ".git", "build", "dist"] +analyzeUnannotatedFunctions = true +deprecateTypingAliases = false +disableBytesTypePromotions = true +enableExperimentalFeatures = false +enableTypeIgnoreComments = true +extraPaths = ["./tests"] +reportAbstractUsage = "error" +reportArgumentType = "error" +reportAssertAlwaysTrue = "error" +reportAssertTypeFailure = "error" +reportAssignmentType = "error" +reportAttributeAccessIssue = "error" +reportCallInDefaultInitializer = true +reportCallIssue = "error" +reportConstantRedefinition = "error" +reportDeprecated = "error" +reportDuplicateImport = "error" +reportFunctionMemberAccess = "error" +reportGeneralTypeIssues = "error" +reportImplicitOverride = true +reportImplicitStringConcatenation = false +reportImportCycles = true +reportIncompatibleMethodOverride = "error" +reportIncompatibleVariableOverride = "error" +reportIncompleteStub = "error" +reportInconsistentConstructor = "error" +reportInconsistentOverload = "error" +reportIndexIssue = "error" +reportInvalidStringEscapeSequence = "error" +reportInvalidStubStatement = "error" +reportInvalidTypeArguments = "error" +reportInvalidTypeForm = "error" +reportInvalidTypeVarUse = "error" +reportMatchNotExhaustive = "error" +reportMissingImports = "error" +reportMissingModuleSource = "warning" +reportMissingParameterType = "error" +reportMissingSuperCall = "none" +reportMissingTypeArgument = "error" +reportMissingTypeStubs = false +reportNoOverloadImplementation = "error" +reportOperatorIssue = "error" +reportOptionalCall = "error" +reportOptionalContextManager = "error" +reportOptionalIterable = "error" +reportOptionalMemberAccess = "error" +reportOptionalOperand = "error" +reportOptionalSubscript = "error" +reportOverlappingOverload = "error" +reportPossiblyUnboundVariable = "error" +reportPrivateImportUsage = "error" +reportPrivateUsage = "error" +reportPropertyTypeMismatch = true +reportRedeclaration = "error" +reportReturnType = "error" +reportSelfClsParameterName = "error" +reportTypeCommentUsage = "error" +reportTypedDictNotRequiredAccess = "error" +reportUnboundVariable = "error" +reportUndefinedVariable = "error" +reportUninitializedInstanceVariable = "none" +reportUnknownArgumentType = "error" +reportUnknownLambdaType = "error" +reportUnknownMemberType = "error" +reportUnknownParameterType = "error" +reportUnknownVariableType = "error" +reportUnnecessaryCast = "error" +reportUnnecessaryComparison = "error" +reportUnnecessaryContains = "error" +reportUnnecessaryIsInstance = "error" +reportUnnecessaryTypeIgnoreComment = "none" +reportUnsupportedDunderAll = "error" +reportUntypedBaseClass = "error" +reportUntypedClassDecorator = "error" +reportUntypedFunctionDecorator = "error" +reportUntypedNamedTuple = "error" +reportUnusedCallResult = "none" +reportUnusedClass = "error" +reportUnusedCoroutine = "error" +reportUnusedExcept = "error" +reportUnusedExpression = "error" +reportUnusedFunction = "error" +reportUnusedImport = "none" +reportUnusedVariable = "error" +reportWildcardImportFromLibrary = "error" +strictDictionaryInference = true +strictListInference = true +strictParameterNoneValue = true +strictSetInference = true +typeCheckingMode = "strict" + +[tool.ruff] +exclude = [ + ".cursor", + ".git", + ".github", + ".mypy_cache", + ".ruff_cache", + ".venv", + ".vscode", +] + +line-length = 150 +target-version = "py311" + +[tool.ruff.format] + +[tool.ruff.lint] +preview = true +select = ["ALL"] +ignore = [ + "ANN201", # Missing return type annotation for public function `my_func` + "ANN202", # Missing return type annotation for private function `my_func` + "ANN204", # Missing return type annotation for special method `my_func` + "ANN206", # Missing return type annotation for classmethod `my_func` + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `...` + "ASYNC230", # Async functions should not open files with blocking methods like `open` + "ASYNC240", # Async functions should not use pathlib.Path methods, use trio.Path or anyio.path + + "B903", # Class could be dataclass or namedtuple + + "C901", # Is to complex + "COM812", # Checks for the absence of trailing commas. + + "CPY001", # Missing copyright notice at top of file + + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D205", # 1 blank line required between summary line and description + "D400", # First line should end with a period + "D401", # First line of docstring should be in imperative mood: "My docstring...." + "D404", # First word of the docstring should not be "This" + "D415", # First line should end with a period, question mark, or exclamation point + + "DOC201", # `return` is not documented in docstring + "DOC202", # Docstring should not have a returns section because the function doesn't return anything + "DOC402", # `yield` is not documented in docstring + "DOC502", # Raised exception is not explicitly raised: `FileNotFoundError` + "DOC501", # Raised exception `ModuleFileError` missing from docstring + + "DTZ001", # `datetime.datetime()` called without a `tzinfo` argument + "DTZ005", # `datetime.datetime.now()` called without a `tz` argument + + "ERA001", # Found commented-out code + + "FBT001", # Boolean-typed positional argument in function definition + "FBT002", # Boolean default positional argument in function definition + "FBT003", #Boolean positional value in function call + + "FIX002", # Line contains TODO, consider resolving the issue + + "FURB101", # `open` and `read` should be replaced by `Path(file_path.path).read_text(encoding="utf-8")` + "FURB113", # Checks for consecutive calls to append. + "FURB152", # Checks for literals that are similar to constants in math module. + + "LOG004", # `.exception()` call outside exception handlers + + "PLC0105", # `TypeVar` name "SomethingType" does not reflect its covariance; consider renaming it to "SomethingType_co" + "PLC1901", # Checks for comparisons to empty strings. + + "PLR0904", # Too many public methods ( > 20) + "PLR0911", # Too many return statements (/6) + "PLR0912", # Too many branches (/12) + "PLR0913", # Too many arguments in function definition (/5) + "PLR0914", # Too many local variables ( /15) + "PLR0915", # Too many statements (/50) + "PLR0917", # Too many positional arguments ( /5) + "PLR2004", # Magic value used in comparison, consider replacing `2` with a constant variable + "PLR6301", # Too many return statements in `for` loop + "PLR1702", # Too many nested blocks ( > 5) + + "PT013", # Incorrect import of `pytest`; use `import pytest` instead + + "PTH100", # `os.path.abspath()` should be replaced by `Path.resolve()` + "PTH103", # `os.makedirs()` should be replaced by `Path.mkdir(parents=True)` + "PTH107", # `os.remove()` should be replaced by `Path.unlink()` + "PTH109", # `os.getcwd()` should be replaced by `Path.cwd()` + "PTH118", # `os.path.join()` should be replaced by `Path` with `/` operator + "PTH120", # `os.path.dirname()` should be replaced by `Path.parent` + "PTH110", # `os.path.exists()` should be replaced by `Path.exists()` + "PTH112", # `os.path.isdir()` should be replaced by `Path.is_dir()` + "PTH119", # `os.path.basename()` should be replaced by `Path.name` + "PTH123", # `open()` should be replaced by `Path.open()` + "PTH208", # Use `pathlib.Path.iterdir()` instead. + + "PYI051", # `Literal["auto"]` is redundant in a union with `str` + + "RET505", # superfluous-else-return + + "RUF001", # String contains ambiguous `′` (PRIME). Did you mean ``` (GRAVE ACCENT)? + "RUF003", # Comment contains ambiguous `’` (RIGHT SINGLE QUOTATION MARK). Did you mean ``` (GRAVE ACCENT)? + "RUF022", # Checks for __all__ definitions that are not ordered according to an "isort-style" sort. + + "SIM105", # Use `contextlib.suppress(ValueError)` instead of `try`-`except`-`pass` + "SIM108", # Use ternary operator `description = func.__doc__.strip().split("\n")[0] if func.__doc__ else func.__name__` instead of `if`-`else`-block + + "S101", # Use of `assert` detected + "S102", # Use of `exec` detected + "S106", # Possible hardcoded password assigned to argument: "secret" + "S105", # Possible hardcoded password assigned to: "child_secret" + + "S311", # Cryptographically weak pseudo-random number generator + + "TD002", # Missing author in TODO; try: `# TODO(): ...` or `# TODO @: ...` + "TD003", # Missing issue link for this TODO + + "T201", # `print` found + + # TODO: stop ignoring these rules + "BLE001", # Do not catch blind exception: `Exception` + "B027", # Checks for empty methods in abstract base classes without an abstract decorator. + "UP007", # Use `X | Y` for type annotations + "UP036", # Version block is outdated for minimum Python version + "SIM102", # Use a single `if` statement instead of nested `if` statements + "S701", # Using jinja2 templates with `autoescape=False` is dangerous and can lead to XSS. Ensure `autoescape=True` or use the `select_autoescape` function. + "TRY301", # Abstract `raise` to an inner function + "PERF401", # Use a list comprehension to create a transformed list + "PLW2901", # `for` loop variable `line` overwritten by assignment target + "TRY300", # Consider moving this statement to an `else` block + "UP035", # `typing.List` is deprecated, use `list` instead + "RET503", # Missing explicit `return` at the end of function able to return non-`None` value + "UP017", # Use `datetime.UTC` alias - but UTC only available in Python 3.11+ +] + +[tool.ruff.lint.flake8-type-checking] +runtime-evaluated-base-classes = ["pydantic.BaseModel"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "INP001", # Allow test files to not have __init__.py in their directories (avoids namespace collisions) +] +"examples/**/*.py" = [ + "INP001", # Runnable demo scripts, not an importable package + "T201", # print() is the whole point of a demo script +] + +[tool.uv] +required-version = ">=0.7.2" + +[tool.pylint.main] +py-version = "3.11" +reports = false + +[tool.pylint.messages_control] +disable = ["all"] +enable = [ + "W0101", # Unreachable code: Used when there is some code behind a "return" or "raise" statement, which will never be accessed. + "C0103", # invalid-name (naming convention) +] +ignore = [".venv", "__pycache__", "build", "dist", ".git"] + +[tool.pylint.basic] +# Variables / attributes / arguments: snake_case, not length 1 +variable-rgx = "^(?!.$)[a-z_][a-z0-9_]*$" +argument-rgx = "^(?!.$)[a-z_][a-z0-9_]*$" +attr-rgx = "^(?!.$)[a-z_][a-z0-9_]*$" + +# Functions / methods: snake_case, not length 1 +function-rgx = "^(?!.$)[a-z_][a-z0-9_]*$" +method-rgx = "^(?!.$)[a-z_][a-z0-9_]*$" + +# Classes: CapWords, not length 1 +class-rgx = "^(?!.$)[A-Z_][a-zA-Z0-9_]*$" +typevar-rgx = "^[A-Za-z_][A-Za-z0-9_]*$" +good-names = ["_"] diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py new file mode 100644 index 0000000..00a8534 --- /dev/null +++ b/tests/unit/test_smoke.py @@ -0,0 +1,6 @@ +import pipelex_sdk + + +class TestPackageImport: + def test_package_name(self): + assert pipelex_sdk.__name__ == "pipelex_sdk" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c2d110f --- /dev/null +++ b/uv.lock @@ -0,0 +1,765 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.15" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[package]] +name = "backports-strenum" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mthds" +version = "0.5.0" +source = { editable = "../mthds-python" } +dependencies = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "semantic-version" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, + { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, + { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, + { name = "pyright", marker = "extra == 'dev'", specifier = "==1.1.408" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0,<9.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0,<4.0.0" }, + { name = "pytest-sugar", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.13" }, + { name = "semantic-version", specifier = ">=2.10.0,<3.0.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0,<3.0.0" }, + { name = "tomlkit", specifier = ">=0.12.0" }, + { name = "typing-extensions", specifier = ">=4.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +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 = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "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 = "pipelex-sdk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "httpx" }, + { name = "mthds" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pylint" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-sugar" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, + { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, + { name = "mthds", editable = "../mthds-python" }, + { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, + { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, + { name = "pyright", marker = "extra == 'dev'", specifier = "==1.1.408" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0,<9.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0,<4.0.0" }, + { name = "pytest-sugar", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.13" }, + { name = "typing-extensions", specifier = ">=4.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, + { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, + { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, + { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +] + +[[package]] +name = "semantic-version" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +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" }, +] From e050980131e4c79e7247b5492237650f39234746 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:14:29 +0200 Subject: [PATCH 02/15] feat(phase-1): client skeleton, transport extensions & base errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PipelexAPIClient(MthdsAPIClient) — Pipelex-branded construction plus the transport/error layer the product and lifecycle phases build on. - errors.py: ApiResponseError (RFC 9457 `code` discriminant + problem-details) and ApiUnreachableError, both deriving from the protocol-base PipelineRequestError. - client.py: __init__ override (PIPELEX_API_KEY/URL → mthds fallback; token optional/anonymous; host-only base-URL validation; origin_url for /health), start_client override (omit the Authorization header when anonymous), and the transport helpers _send_or_unreachable (httpx transport → ApiUnreachableError), _request_product (typed ApiResponseError, empty-body tolerant, PUT/PATCH/DELETE), _request_json (plainer regime), and the _parse_error_body problem+json parser. - Tests (test-first style): construction (env precedence, optional token, base-URL validation), _parse_error_body across body shapes, _request_product (2xx/empty-body/non-2xx code mapping/transport), _request_json regime. - Config: allow SLF001/PLC2701 in tests (probe internal helpers) and a pyright executionEnvironment disabling reportPrivateUsage under tests/. Gate green: make check (ruff, pyright 0 errors, mypy, pylint 10.00/10) + make agent-test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013cPeza9ezw38JFCi3uXP4m --- CHANGELOG.md | 3 + docs/architecture.md | 21 +- pipelex_sdk/client.py | 277 +++++++++++++++++++++++++ pipelex_sdk/errors.py | 80 +++++++ pyproject.toml | 11 +- tests/unit/test_client_construction.py | 75 +++++++ tests/unit/test_client_transport.py | 108 ++++++++++ tests/unit/test_error_parsing.py | 45 ++++ tests/unit/test_smoke.py | 6 - 9 files changed, 615 insertions(+), 11 deletions(-) create mode 100644 pipelex_sdk/client.py create mode 100644 pipelex_sdk/errors.py create mode 100644 tests/unit/test_client_construction.py create mode 100644 tests/unit/test_client_transport.py create mode 100644 tests/unit/test_error_parsing.py delete mode 100644 tests/unit/test_smoke.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 669fe1f..cb9373b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,6 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke ### Added - Initial repository scaffold: packaging (`pyproject.toml`), tooling (`Makefile`, ruff/pyright/mypy/pylint config mirroring `mthds-python`), and the empty `pipelex_sdk` package. +- `PipelexAPIClient` (subclass of `mthds`'s `MthdsAPIClient`): Pipelex-branded construction (resolves `PIPELEX_API_KEY` / `PIPELEX_API_URL`, falling back to the `mthds` resolver; token optional for anonymous access; host-only base-URL validation; origin URL for `health`). +- Transport extension layer: `_request_product` (typed `ApiResponseError` mapping, empty-body tolerant, PUT/PATCH/DELETE), `_request_json` (plainer error regime), transport-failure mapping to `ApiUnreachableError`, and the `problem+json` error-body parser. +- Errors: `ApiResponseError` (with the RFC 9457 `code` discriminant) and `ApiUnreachableError`, both deriving from the protocol-base `PipelineRequestError`. diff --git a/docs/architecture.md b/docs/architecture.md index 5fb8156..dba8a67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,13 +44,26 @@ A token is **optional** (anonymous access is allowed; protocol routes work again - **No barrel** — package `__init__.py` files stay empty; consumers import via full paths (`from pipelex_sdk.client import PipelexAPIClient`). The public import paths are documented in the README. - **Wire format** — snake_case JSON fields on Pydantic v2 models. +## Transport layer + +The client inherits `mthds`'s `_send` (one raw HTTP request, no status interpretation) and `_url` (`{base}/v1/{endpoint}`), and layers three helpers on top (`pipelex_sdk/client.py`): + +- **`_send_or_unreachable`** — wraps `_send`, mapping httpx transport failures to `ApiUnreachableError` (a `httpx.TimeoutException` → `code="ABORT_TIMEOUT"`; any other `httpx.TransportError` → `code=`). Non-2xx interpretation stays with the caller. +- **`_request_product`** — the product-route path. Serializes the body with `pydantic_core.to_json` (supporting PUT/PATCH/DELETE as well as GET/POST), uses the management-call timeout, maps a non-2xx response to `ApiResponseError`, and is **empty-body tolerant** (a 2xx with no body — DELETE / onboarding / update — returns `None`). +- **`_request_json`** — the plainer path for `health` (and, if ever added, the build extensions). Takes an absolute URL, raises `PipelineRequestError` on a non-2xx response. Transport failures still map to `ApiUnreachableError`. + +`start_client` is overridden so the `Authorization` header is sent only when a token is configured — anonymous access (empty token) omits it. + +The `problem+json` / `HTTPException` error body is parsed by `_parse_error_body` into `(error_type, server_message, validation_errors, code)`, handling both `{"detail": {...}}` and `{"detail": "..."}` shapes plus top-level `error_type` / `message` / `code`, and falling through to empty on a non-JSON or non-object body. `validation_errors` is parsed leniently (best-effort error-path enrichment; only reachable via the out-of-scope build-route 422s). + ## Error regimes -(To be detailed in Phase 1.) Two regimes, ported from the TS SDK: +Two regimes, ported faithfully from the TS SDK (decision #5 — not unified yet): -- **Product routes** raise a typed `ApiResponseError` carrying the RFC 9457 `.code` discriminant — consumers branch on `err.code` (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`), never on the HTTP status. -- **Transport failures** (DNS/connect/TLS/timeout) raise `ApiUnreachableError`. -- **Inherited protocol routes** keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. +- **Product routes** raise a typed `ApiResponseError` (subclass of `PipelineRequestError`) carrying the RFC 9457 `code` discriminant — consumers branch on `err.code` (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`), never on the HTTP status. It also carries `status`, `status_text`, `response_body`, `error_type`, `server_message`, and `validation_errors`. +- **Transport failures** (DNS/connect/TLS/timeout) raise `ApiUnreachableError` (subclass of `PipelineRequestError`) with `api_url` and `code`. +- **`health` / `_request_json`** raise the plainer `PipelineRequestError` on a non-2xx response (decision #5 revisits whether to bring this under `ApiResponseError` at Checkpoint 5). +- **Inherited protocol routes** (`execute` / `start` / `validate` / `models` / `version`) keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. ## Out of scope for v0.1 diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py new file mode 100644 index 0000000..f7dc0b1 --- /dev/null +++ b/pipelex_sdk/client.py @@ -0,0 +1,277 @@ +"""`PipelexAPIClient` — the Python client for the Pipelex hosted API. + +Built by inheritance on `mthds`'s protocol base (`MthdsAPIClient`): the protocol +routes (`execute` / `start` / `validate` / `models` / `version`), the transport +(`_send`, `_url`), and the request-body builders are reused; this client adds the +Pipelex branding (env resolution, optional token, host-only base-URL validation), +the richer transport/error layer the product and lifecycle phases build on, and — +in later phases — the durable run lifecycle, the product surface, and `health`. + +This module currently holds Phase 1: construction, the transport extension helpers +(`_request_product`, `_request_json`, `_send_or_unreachable`), and the `problem+json` +error-body parser. Lifecycle and product methods land in Phases 2-4. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, NamedTuple, NoReturn, cast +from urllib.parse import urlparse + +import httpx +from mthds.config.credentials import load_credentials +from mthds.protocol.exceptions import PipelineRequestError +from mthds.runners.api.client import MthdsAPIClient +from mthds.runners.api.models import ValidationErrorItem +from pydantic import TypeAdapter, ValidationError +from pydantic_core import to_json +from typing_extensions import override + +from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError + +# The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`. +# The same paths are served by the Pipelex Hosted API (api.pipelex.com) and by a bare +# OSS pipelex-api runner (localhost:8081) — the protocol surface is identical; only the +# hosted extensions (e.g. run polling) differ, detectable via GET /v1/version. +_API_PREFIX = "v1" + +#: Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. +DEFAULT_API_BASE_URL = "https://api.pipelex.com" + +_DEFAULT_REQUEST_TIMEOUT_SECONDS = 1200.0 # 20 min — matches the runner's blocking-execute ceiling. +_POLL_REQUEST_TIMEOUT_SECONDS = 30.0 # single status/result/product GETs; the hosted gateway caps responses at ~30s. +_DEFAULT_DEGRADED_RETRY_SECONDS = 5 # matches the platform's `_DEGRADE_RETRY_AFTER_SECONDS`. + +_PIPELEX_API_KEY_ENV = "PIPELEX_API_KEY" +_PIPELEX_API_URL_ENV = "PIPELEX_API_URL" + + +class PipelexAPIClient(MthdsAPIClient): + """Client for the Pipelex hosted API — and any MTHDS-compliant runner. + + One base URL (`PIPELEX_API_URL`); every endpoint is `/v1/`: + - **protocol** (`execute` / `start` / `validate` / `models` / `version`) — inherited + from `MthdsAPIClient`; works against any MTHDS-compliant runner, hosted or bare. + - **run lifecycle** (`get_run_status` / `get_run_result` / `wait_for_result`) — the + durable polling extension (added in Phase 2). + - **product** (`/v1/me`, `/v1/methods`, `/v1/billing/*`, …) — the hosted product + surface (added in Phase 3), reached through `_request_product` so callers branch + on the structured `ApiResponseError.code`, not the HTTP status. + + Construction resolves credentials Pipelex-first (`PIPELEX_API_KEY` / + `PIPELEX_API_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` / + `MTHDS_API_URL`, `~/.mthds/config`). The token is optional — anonymous access works + against the protocol routes; product routes return `401`. The base URL is validated + host-only (no path/query/fragment/credentials; http/https only). + """ + + def __init__(self, api_token: str | None = None, api_base_url: str | None = None) -> None: + credentials = load_credentials() + + # Pipelex-primary, mthds fallback. `credentials` already layers env (MTHDS_*) > + # file (~/.mthds/config) > default, so this `or` chain gives the full precedence: + # explicit arg > PIPELEX_* env > MTHDS_* env > file > default. Empty string ("") + # means anonymous — the token is optional. + self.api_token: str = api_token or os.environ.get(_PIPELEX_API_KEY_ENV) or credentials["api_key"] + + resolved_base_url = api_base_url or os.environ.get(_PIPELEX_API_URL_ENV) or credentials["api_url"] or DEFAULT_API_BASE_URL + normalized_base_url = resolved_base_url.rstrip("/") + # The base URL must be host-only: a path-prefixed value (e.g. `.../v1`) would + # compose as `/v1/v1/...` and fail with a misleading endpoint error instead of a + # clear base-URL one. Trailing slashes are stripped first; any remaining + # path/query/fragment/credentials is rejected. + if not _is_valid_base_url(normalized_base_url): + msg = ( + f'Invalid API base URL "{normalized_base_url}": must be host-only ' + "(http/https, no path, query, fragment, or credentials). " + "Endpoints compose as {base}/v1/{endpoint}." + ) + raise PipelineRequestError(msg) + self.api_base_url: str = normalized_base_url + #: Origin root derived from the base URL — `/health` lives here, not under `/v1`. + self.origin_url: str = _origin_of(normalized_base_url) + self.client: httpx.AsyncClient | None = None + + @override + def start_client(self) -> PipelexAPIClient: + """Initialize the HTTP client. The Authorization header is sent only when a token + is configured — anonymous access (empty token) omits it, matching the JS SDK. + """ + headers = {"Authorization": f"Bearer {self.api_token}"} if self.api_token else {} + self.client = httpx.AsyncClient(headers=headers) + return self + + # ── Transport extensions (layered on the inherited `_send`) ────────── + + async def _send_or_unreachable(self, method: str, url: str, *, content: bytes | None, request_timeout: float) -> httpx.Response: + """Issue one request via the inherited `_send`, mapping transport failures + (DNS / connect / TLS / timeout) to `ApiUnreachableError`. Non-2xx interpretation + stays the caller's — `_send` returns the raw response without raising on status. + """ + try: + return await self._send(method, url, content=content, request_timeout=request_timeout) + except httpx.TimeoutException as exc: + msg = f"Could not reach Pipelex API at {self.api_base_url} (timeout)" + raise ApiUnreachableError(msg, api_url=self.api_base_url, code="ABORT_TIMEOUT") from exc + except httpx.TransportError as exc: + code = type(exc).__name__ + msg = f"Could not reach Pipelex API at {self.api_base_url} ({code})" + raise ApiUnreachableError(msg, api_url=self.api_base_url, code=code) from exc + + async def _request_product(self, method: str, endpoint: str, *, body: object | None = None) -> Any: + """Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`, …) + and parse its JSON body, mapping a non-2xx response to the typed `ApiResponseError` + so callers branch on the structured `code` discriminant, not the HTTP status. + + Empty-body tolerant — DELETE / onboarding / update routes answer 2xx with no body, + returned as `None`. Uses the management-call timeout, not the blocking ceiling. + """ + content = to_json(body) if body is not None else None + response = await self._send_or_unreachable(method, self._url(endpoint), content=content, request_timeout=_POLL_REQUEST_TIMEOUT_SECONDS) + if not 200 <= response.status_code < 300: + self._raise_api_response_error(method=method, endpoint=endpoint, response=response) + if not response.content: + return None + return response.json() + + async def _request_json(self, method: str, url: str, *, body: object | None = None) -> Any: + """Issue a request to an absolute URL and parse the JSON body, raising the plainer + `PipelineRequestError` on a non-2xx response. Used by `health` (origin-level) and + the build extensions — surfaces that don't need the product `code` taxonomy. + Transport failures still map to `ApiUnreachableError`. + """ + content = to_json(body) if body is not None else None + response = await self._send_or_unreachable(method, url, content=content, request_timeout=_POLL_REQUEST_TIMEOUT_SECONDS) + if not 200 <= response.status_code < 300: + detail = response.text or response.reason_phrase + msg = f"API {method} {url} failed ({response.status_code}): {detail}" + raise PipelineRequestError(msg) + return response.json() + + def _raise_api_response_error(self, *, method: str, endpoint: str, response: httpx.Response) -> NoReturn: + """Parse an error response and raise the typed `ApiResponseError`.""" + body_text = response.text + parsed = _parse_error_body(body_text) + detail = parsed.server_message or body_text or response.reason_phrase + msg = f"API {method} /{_API_PREFIX}/{endpoint} failed ({response.status_code}): {detail}" + raise ApiResponseError( + msg, + api_url=self.api_base_url, + status=response.status_code, + status_text=response.reason_phrase, + response_body=body_text, + error_type=parsed.error_type, + server_message=parsed.server_message, + validation_errors=parsed.validation_errors, + code=parsed.code, + ) + + +# ── Module helpers ────────────────────────────────────────────────────── + + +def _is_valid_base_url(value: str) -> bool: + """Whether a base URL is host-only — http/https, no path, query, fragment, or + embedded credentials (auth travels in the Authorization header, never the URL). + Endpoints compose as `{base}/v1/{endpoint}`, so a path-prefixed base would double + the prefix. + """ + try: + parsed = urlparse(value) + except ValueError: + return False + if parsed.scheme not in {"http", "https"}: + return False + if not parsed.netloc: + return False + if parsed.path not in {"", "/"}: + return False + if parsed.username or parsed.password: + return False + return not parsed.query and not parsed.fragment + + +def _origin_of(base_url: str) -> str: + """Derive the origin (`scheme://host[:port]`) from a validated host-only base URL. + + `/health` is served at the origin, not under the `/v1` prefix. + """ + parsed = urlparse(base_url) + return f"{parsed.scheme}://{parsed.netloc}" + + +class _ParsedErrorBody(NamedTuple): + """The fields pulled out of a `problem+json` / `HTTPException` error body.""" + + error_type: str | None + server_message: str | None + validation_errors: list[ValidationErrorItem] | None + code: str | None + + +_EMPTY_ERROR_BODY = _ParsedErrorBody(error_type=None, server_message=None, validation_errors=None, code=None) + +# The build routes' 422s carry a top-level `validation_errors[]`. Validated leniently +# (best-effort error-path enrichment) so an odd shape never masks the underlying failure. +_VALIDATION_ERRORS_ADAPTER: TypeAdapter[list[ValidationErrorItem]] = TypeAdapter(list[ValidationErrorItem]) + + +def _parse_error_body(body: str) -> _ParsedErrorBody: + """Extract `error_type` / `message` / `validation_errors` / `code` from an error body. + + The API serializes errors as `{"detail": {"error_type": ..., "message": ...}}` + (HTTPException with dict detail) or `{"detail": "..."}` (auth 401s and RFC 7807 + problems); both shapes are handled, with top-level `error_type` / `message` + fallbacks. The product routes' RFC 9457 `problem+json` adds a stable top-level + `code` discriminant. Falls through to empty on a non-JSON or non-object body. + """ + if not body: + return _EMPTY_ERROR_BODY + try: + parsed = json.loads(body) + except ValueError: + return _EMPTY_ERROR_BODY + if not isinstance(parsed, dict): + return _EMPTY_ERROR_BODY + root = cast("dict[str, Any]", parsed) + + error_type: str | None = None + server_message: str | None = None + detail = root.get("detail") + if isinstance(detail, dict): + detail_dict = cast("dict[str, Any]", detail) + raw_error_type = detail_dict.get("error_type") + if isinstance(raw_error_type, str): + error_type = raw_error_type + raw_message = detail_dict.get("message") + if isinstance(raw_message, str): + server_message = raw_message + elif isinstance(detail, str): + server_message = detail + if error_type is None: + top_error_type = root.get("error_type") + if isinstance(top_error_type, str): + error_type = top_error_type + if server_message is None: + top_message = root.get("message") + if isinstance(top_message, str): + server_message = top_message + + validation_errors: list[ValidationErrorItem] | None = None + raw_validation_errors = root.get("validation_errors") + if isinstance(raw_validation_errors, list): + try: + validation_errors = _VALIDATION_ERRORS_ADAPTER.validate_python(raw_validation_errors) + except ValidationError: + # Best-effort error-path enrichment: an odd validation_errors shape (only + # reachable via the out-of-scope /v1/build/* 422s) must not mask the + # underlying API failure — server_message still carries the problem. + validation_errors = None + + code: str | None = None + raw_code = root.get("code") + if isinstance(raw_code, str): + code = raw_code + + return _ParsedErrorBody(error_type=error_type, server_message=server_message, validation_errors=validation_errors, code=code) diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py new file mode 100644 index 0000000..8b38a35 --- /dev/null +++ b/pipelex_sdk/errors.py @@ -0,0 +1,80 @@ +"""Pipelex SDK errors — the transport and response errors raised by `PipelexAPIClient`. + +These are the error classes the Pipelex hosted client adds on top of the `mthds` +protocol base. Both derive from the protocol base `PipelineRequestError` +(`mthds.protocol.exceptions`), mirroring `pipelex-sdk-js/src/errors.ts`: + +- `ApiUnreachableError` — the HTTP exchange never produced a response (DNS / connect + / TLS / timeout). Distinguished from `ApiResponseError`, which represents a non-2xx + response that *did* come back. +- `ApiResponseError` — a non-2xx response from the API, carrying the parsed + problem-details and, for the product routes, the stable RFC 9457 `code` discriminant + a consumer branches on (decoupled from the HTTP status). + +The run-lifecycle errors (`RunFailedError`, `RunTimeoutError`, +`RunLifecycleUnavailableError`) are added here in Phase 2; `RunStillRunningError` +stays in `mthds` (it belongs to the protocol `execute()` 202-degrade path) and is +imported by consumers from there. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mthds.protocol.exceptions import PipelineRequestError + +if TYPE_CHECKING: + from mthds.runners.api.models import ValidationErrorItem + + +class ApiUnreachableError(PipelineRequestError): + """Raised when the Pipelex API host cannot be reached at all. + + DNS failure, connection refused, TLS handshake failure, or a request timeout — + the HTTP exchange never produced a response. Distinguish from `ApiResponseError`, + which represents a non-2xx response that did come back. + + `code` is the underlying transport-failure class when available (`ABORT_TIMEOUT` + for a timeout, otherwise the httpx transport exception class name). + """ + + def __init__(self, message: str, api_url: str, code: str | None = None) -> None: + super().__init__(message) + self.api_url = api_url + self.code = code + + +class ApiResponseError(PipelineRequestError): + """A non-2xx response that DID come back from the API. + + Carries the parsed RFC 7807 problem-details (`error_type`, `server_message`) and, + for the build routes' 422s, the structured `validation_errors` list. + + `code` is the product routes' stable RFC 9457 `problem+json` discriminant + (`conflict`, `not_found`, `pipelex_api_key_limit_reached`, …) — the field a + consumer branches on, decoupled from the HTTP status. `None` for any error body + that carries no `code` (the protocol/build routes' `detail`-shaped problems). + """ + + def __init__( + self, + message: str, + *, + api_url: str, + status: int, + status_text: str, + response_body: str, + error_type: str | None = None, + server_message: str | None = None, + validation_errors: list[ValidationErrorItem] | None = None, + code: str | None = None, + ) -> None: + super().__init__(message) + self.api_url = api_url + self.status = status + self.status_text = status_text + self.response_body = response_body + self.error_type = error_type + self.server_message = server_message + self.validation_errors = validation_errors + self.code = code diff --git a/pyproject.toml b/pyproject.toml index 3ac1870..69b7d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,6 +168,13 @@ strictParameterNoneValue = true strictSetInference = true typeCheckingMode = "strict" +# Unit tests legitimately probe internal transport/error helpers (e.g. +# _request_product, _request_json, _parse_error_body) before the public surface +# wraps them — allow private access in the test tree only. +[[tool.pyright.executionEnvironments]] +root = "tests" +reportPrivateUsage = "none" + [tool.ruff] exclude = [ ".cursor", @@ -314,7 +321,9 @@ convention = "google" [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ - "INP001", # Allow test files to not have __init__.py in their directories (avoids namespace collisions) + "INP001", # Allow test files to not have __init__.py in their directories (avoids namespace collisions) + "SLF001", # Unit tests legitimately probe private transport/error helpers (e.g. _request_product, _request_json) + "PLC2701", # Unit tests legitimately import private module helpers under test (e.g. _parse_error_body) ] "examples/**/*.py" = [ "INP001", # Runnable demo scripts, not an importable package diff --git a/tests/unit/test_client_construction.py b/tests/unit/test_client_construction.py new file mode 100644 index 0000000..9973775 --- /dev/null +++ b/tests/unit/test_client_construction.py @@ -0,0 +1,75 @@ +"""Tests for `PipelexAPIClient` construction — credential resolution and base-URL validation.""" + +import os + +import pytest +from mthds.protocol.exceptions import PipelineRequestError +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient + +_MTHDS_DEFAULT_CREDENTIALS = {"api_key": "", "api_url": "https://api.pipelex.com", "runner": "api", "telemetry": "0"} + + +class TestClientConstruction: + @pytest.fixture(autouse=True) + def _isolate_env(self, mocker: MockerFixture) -> None: + """Hermetic construction — no real env vars, mthds resolver returns defaults.""" + mocker.patch.dict(os.environ, {}, clear=True) + mocker.patch("pipelex_sdk.client.load_credentials", return_value=dict(_MTHDS_DEFAULT_CREDENTIALS)) + + def test_defaults_to_hosted_base_and_anonymous(self) -> None: + client = PipelexAPIClient() + assert client.api_base_url == "https://api.pipelex.com" + assert client.origin_url == "https://api.pipelex.com" + assert client.api_token == "" + + def test_pipelex_env_takes_precedence_over_mthds(self, mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-live", "PIPELEX_API_URL": "http://localhost:8081"}, clear=True) + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"}, + ) + client = PipelexAPIClient() + assert client.api_token == "pk-live" + assert client.api_base_url == "http://localhost:8081" + + def test_falls_back_to_mthds_credentials(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"}, + ) + client = PipelexAPIClient() + assert client.api_token == "mthds-key" + assert client.api_base_url == "https://mthds.example.com" + + def test_explicit_args_override_env_and_credentials(self, mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-env", "PIPELEX_API_URL": "http://env.example.com"}, clear=True) + client = PipelexAPIClient(api_token="arg-token", api_base_url="https://arg.example.com") + assert client.api_token == "arg-token" + assert client.api_base_url == "https://arg.example.com" + + def test_strips_trailing_slash(self) -> None: + client = PipelexAPIClient(api_base_url="https://api.pipelex.com/") + assert client.api_base_url == "https://api.pipelex.com" + assert client.origin_url == "https://api.pipelex.com" + + def test_origin_includes_port(self) -> None: + client = PipelexAPIClient(api_base_url="http://localhost:8081") + assert client.origin_url == "http://localhost:8081" + + @pytest.mark.parametrize( + "bad_url", + [ + "https://api.pipelex.com/v1", # path + "https://api.pipelex.com?x=1", # query + "https://api.pipelex.com#frag", # fragment + "https://user:pass@api.pipelex.com", # embedded credentials + "ftp://api.pipelex.com", # non-http(s) scheme + "api.pipelex.com", # no scheme + "not a url", # garbage + ], + ) + def test_rejects_non_host_only_base_url(self, bad_url: str) -> None: + with pytest.raises(PipelineRequestError): + PipelexAPIClient(api_base_url=bad_url) diff --git a/tests/unit/test_client_transport.py b/tests/unit/test_client_transport.py new file mode 100644 index 0000000..fc6ec60 --- /dev/null +++ b/tests/unit/test_client_transport.py @@ -0,0 +1,108 @@ +"""Tests for the transport extension layer — `_request_product` / `_request_json`, httpx mocked.""" + +import asyncio + +import httpx +import pytest +from mthds.protocol.exceptions import PipelineRequestError +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError + +_BASE_URL = "http://localhost:8081" + + +def _response(status_code: int, *, json: object | None = None, content: bytes | None = None, headers: dict[str, str] | None = None) -> httpx.Response: + """Build a constructed httpx.Response with a request attached.""" + request = httpx.Request("GET", f"{_BASE_URL}/x") + if json is not None: + return httpx.Response(status_code, json=json, headers=headers or {}, request=request) + if content is not None: + return httpx.Response(status_code, content=content, headers=headers or {}, request=request) + return httpx.Response(status_code, headers=headers or {}, request=request) + + +class TestClientTransport: + @pytest.fixture(autouse=True) + def _isolate(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="t", api_base_url=_BASE_URL) + + # ── _request_product ───────────────────────────────────────────── + + def test_request_product_parses_2xx_body(self, mocker: MockerFixture) -> None: + client = self._client() + send = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json={"id": "u1"}))) + result = asyncio.run(client._request_product("GET", "me")) + assert result == {"id": "u1"} + assert send.call_args.args[0] == "GET" + assert send.call_args.args[1] == f"{_BASE_URL}/v1/me" + + def test_request_product_empty_body_returns_none(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(204))) + result = asyncio.run(client._request_product("DELETE", "methods/m1")) + assert result is None + + def test_request_product_sends_body_and_verb(self, mocker: MockerFixture) -> None: + client = self._client() + send = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json={"ok": True}))) + asyncio.run(client._request_product("PUT", "runs/r1", body={"name": "x"})) + assert send.call_args.args[0] == "PUT" + assert send.call_args.kwargs["content"] == b'{"name":"x"}' + + def test_request_product_non_2xx_raises_api_response_error_with_code(self, mocker: MockerFixture) -> None: + client = self._client() + body = {"code": "conflict", "detail": {"error_type": "Conflict", "message": "no subscription"}} + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client._request_product("POST", "billing/change-plan", body={"plan": "pro"})) + err = exc_info.value + assert err.code == "conflict" + assert err.status == 409 + assert err.server_message == "no subscription" + assert err.error_type == "Conflict" + assert err.api_url == _BASE_URL + + def test_request_product_connect_failure_maps_to_unreachable(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=httpx.ConnectError("refused"))) + with pytest.raises(ApiUnreachableError) as exc_info: + asyncio.run(client._request_product("GET", "me")) + err = exc_info.value + assert err.api_url == _BASE_URL + assert err.code == "ConnectError" + + def test_request_product_timeout_maps_to_unreachable_abort(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=httpx.ConnectTimeout("slow"))) + with pytest.raises(ApiUnreachableError) as exc_info: + asyncio.run(client._request_product("GET", "me")) + assert exc_info.value.code == "ABORT_TIMEOUT" + + # ── _request_json (plainer regime) ─────────────────────────────── + + def test_request_json_parses_2xx(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json={"status": "ok"}))) + result = asyncio.run(client._request_json("GET", f"{client.origin_url}/health")) + assert result == {"status": "ok"} + + def test_request_json_non_2xx_raises_pipeline_request_error_not_api_response_error(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(500, content=b"boom"))) + with pytest.raises(PipelineRequestError) as exc_info: + asyncio.run(client._request_json("GET", f"{client.origin_url}/health")) + assert not isinstance(exc_info.value, ApiResponseError) + + def test_request_json_transport_failure_maps_to_unreachable(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=httpx.ReadError("reset"))) + with pytest.raises(ApiUnreachableError): + asyncio.run(client._request_json("GET", f"{client.origin_url}/health")) diff --git a/tests/unit/test_error_parsing.py b/tests/unit/test_error_parsing.py new file mode 100644 index 0000000..f44f4a2 --- /dev/null +++ b/tests/unit/test_error_parsing.py @@ -0,0 +1,45 @@ +"""Tests for `_parse_error_body` — the problem+json / HTTPException error-body parser.""" + +import pytest + +from pipelex_sdk.client import _parse_error_body + + +class TestParseErrorBody: + def test_detail_dict_extracts_error_type_and_message(self) -> None: + parsed = _parse_error_body('{"detail": {"error_type": "ValidationError", "message": "bad bundle"}}') + assert parsed.error_type == "ValidationError" + assert parsed.server_message == "bad bundle" + assert parsed.code is None + assert parsed.validation_errors is None + + def test_detail_string_is_server_message(self) -> None: + parsed = _parse_error_body('{"detail": "Not authenticated"}') + assert parsed.server_message == "Not authenticated" + assert parsed.error_type is None + + def test_top_level_error_type_and_message_fallback(self) -> None: + parsed = _parse_error_body('{"error_type": "Boom", "message": "top level"}') + assert parsed.error_type == "Boom" + assert parsed.server_message == "top level" + + def test_extracts_rfc9457_code(self) -> None: + parsed = _parse_error_body('{"code": "conflict", "detail": "already exists"}') + assert parsed.code == "conflict" + assert parsed.server_message == "already exists" + + def test_malformed_validation_errors_falls_back_to_none(self) -> None: + parsed = _parse_error_body('{"validation_errors": [{"unexpected": 1}]}') + assert parsed.validation_errors is None + + def test_absent_validation_errors_is_none(self) -> None: + parsed = _parse_error_body('{"detail": "x"}') + assert parsed.validation_errors is None + + @pytest.mark.parametrize("body", ["", "not json", "[]", "5", "null"]) + def test_non_object_bodies_are_empty(self, body: str) -> None: + parsed = _parse_error_body(body) + assert parsed.error_type is None + assert parsed.server_message is None + assert parsed.code is None + assert parsed.validation_errors is None diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py deleted file mode 100644 index 00a8534..0000000 --- a/tests/unit/test_smoke.py +++ /dev/null @@ -1,6 +0,0 @@ -import pipelex_sdk - - -class TestPackageImport: - def test_package_name(self): - assert pipelex_sdk.__name__ == "pipelex_sdk" From bbe3abde54e1fce5de411acd5ed726d2943f8a5e Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:20:22 +0200 Subject: [PATCH 03/15] docs: record config-scoped test lint-exemption convention Lock in the repo convention (preferred over pipelex's per-line inline ignores): test-only lint/type exemptions are declared once, scoped to tests/** in config (ruff per-file-ignores + pyright executionEnvironments), never sprinkled inline. The pyproject config already follows this; this just documents it so later phases don't drift back to per-line ignores. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013cPeza9ezw38JFCi3uXP4m --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index e1389c5..4345a6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ It is the **hosted superset**: the five normative MTHDS Protocol routes (inherit - Use Make targets: `make install`, `make agent-check`, `make agent-test`, `make check`. - Always run `make agent-check` and `make agent-test` before considering a change done or pushing. - Test-first where practical. `pytest-mock` only (never `unittest.mock`). One `TestClass` per test module. No `__init__.py` in test directories. +- **Test-only lint/type exemptions are config-scoped, not per-line.** When tests legitimately touch internals, exempt the rule **once** for the whole `tests/` tree — ruff `[tool.ruff.lint.per-file-ignores]` `"tests/**"` (e.g. `SLF001`, `PLC2701`) and pyright `[[tool.pyright.executionEnvironments]]` `root = "tests"` (e.g. `reportPrivateUsage = "none"`). Do **not** sprinkle inline `# noqa`/`# pyright: ignore` on each line. Source under `pipelex_sdk/` always stays under the full strict ruleset. - Document every iteration: update `docs/` and `CHANGELOG.md` alongside code. - No hardcoded counts in code/docs/commits. Pre-1.0 breaking changes → minor version bump. From c30fc77258aced3c2efd76b3b1a479212a9e6cdb Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:50:57 +0200 Subject: [PATCH 04/15] feat(phase-2): durable run lifecycle (own + blocking-execute fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own the hosted run lifecycle in pipelex-sdk (ported from mthds-python and enhanced), independent of mthds-python's state — a transient duplication resolved by the Phase 6 strip. - pipelex_sdk/runs.py: owned lifecycle models (RunStatus with is_terminal/ is_success, RunRead, RunResults, the discriminated RunResultState union, WaitForResultOptions, PollInfo). RunResults names the Pipelex hosted artifacts (main_stuff, graph_spec, pipe_output) per the JS behavior spec. - Lifecycle methods on PipelexAPIClient: get_run_status, get_run_result (202/503 -> running, 200 -> completed, 409 -> failed), wait_for_result. Poll GETs go through _send_or_unreachable, so transport failures surface as ApiUnreachableError and missing-route 404s as RunLifecycleUnavailableError. - start override: translates a bare-runner missing-route 404 into a typed RunLifecycleUnavailableError (before any run is created), matching the JS SDK. - start_and_wait self-heals hosted<->bare: cached GET /v1/version handshake (_supports_run_lifecycle) picks durable start+poll on hosted, falls back to the blocking POST /v1/execute (_execute_blocking) on a bare runner. Closes a gap vs mthds-python (whose start_and_wait raises on a bare runner). - Lifecycle errors RunFailedError/RunTimeoutError/RunLifecycleUnavailableError owned here; RunStillRunningError re-exported from mthds (protocol 202-degrade). - Tests: lifecycle status mapping, start override, poll/timeout/cancel, plus new coverage for the version-handshake caching and the bare-runner self-heal. - Docs + CHANGELOG updated. The owned lifecycle methods transiently shadow the base's (until Phase 6 strips them from mthds-python); their owned return types read as incompatible overrides, hence a narrow # type: ignore[override] that becomes harmless after the strip. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013cPeza9ezw38JFCi3uXP4m --- CHANGELOG.md | 3 + docs/architecture.md | 35 +++ pipelex_sdk/client.py | 396 ++++++++++++++++++++++++- pipelex_sdk/errors.py | 57 +++- pipelex_sdk/runs.py | 198 +++++++++++++ tests/unit/test_client_lifecycle.py | 249 ++++++++++++++++ tests/unit/test_client_run_fallback.py | 187 ++++++++++++ tests/unit/test_runs.py | 31 ++ 8 files changed, 1150 insertions(+), 6 deletions(-) create mode 100644 pipelex_sdk/runs.py create mode 100644 tests/unit/test_client_lifecycle.py create mode 100644 tests/unit/test_client_run_fallback.py create mode 100644 tests/unit/test_runs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9373b..b0c3251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,6 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke - `PipelexAPIClient` (subclass of `mthds`'s `MthdsAPIClient`): Pipelex-branded construction (resolves `PIPELEX_API_KEY` / `PIPELEX_API_URL`, falling back to the `mthds` resolver; token optional for anonymous access; host-only base-URL validation; origin URL for `health`). - Transport extension layer: `_request_product` (typed `ApiResponseError` mapping, empty-body tolerant, PUT/PATCH/DELETE), `_request_json` (plainer error regime), transport-failure mapping to `ApiUnreachableError`, and the `problem+json` error-body parser. - Errors: `ApiResponseError` (with the RFC 9457 `code` discriminant) and `ApiUnreachableError`, both deriving from the protocol-base `PipelineRequestError`. +- Durable run lifecycle (`pipelex_sdk/runs.py` + client methods): owned run-lifecycle models (`RunStatus`, `RunRead`, `RunResults`, the discriminated `RunResultState`, `WaitForResultOptions`, `PollInfo`) and the polling surface `get_run_status` / `get_run_result` / `wait_for_result`, mapping the platform's `202`/`503`/`200`/`409` results semantics to a typed union. +- `start_and_wait` self-heals across hosted and bare runners: a cached `GET /v1/version` handshake picks the durable start+poll path on the hosted API and falls back to the blocking `POST /v1/execute` on a bare runner (including the case where a base-only version response hides a missing run store — `start` then surfaces `RunLifecycleUnavailableError` before any run is created). This closes a gap versus `mthds-python` (whose `start_and_wait` raises on a bare runner). +- Lifecycle errors `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`; `RunStillRunningError` (the protocol `execute()` 202-degrade error) re-exported from `mthds` so all run/lifecycle errors share one import home. diff --git a/docs/architecture.md b/docs/architecture.md index dba8a67..4f591d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,41 @@ Two regimes, ported faithfully from the TS SDK (decision #5 — not unified yet) - **`health` / `_request_json`** raise the plainer `PipelineRequestError` on a non-2xx response (decision #5 revisits whether to bring this under `ApiResponseError` at Checkpoint 5). - **Inherited protocol routes** (`execute` / `start` / `validate` / `models` / `version`) keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. +## Run lifecycle (hosted extension) + +The durable run lifecycle (`pipelex_sdk/runs.py` + the client's lifecycle methods) is a **hosted-API extension, not part of the MTHDS Protocol**. Long method runs outlive the hosted gateway's ~30s synchronous cap, so a caller submits a run (`POST /v1/start`), then polls a self-healing endpoint by bare `pipeline_run_id` until it reaches a terminal state. All state lives behind the id (DynamoDB + Temporal on the platform), so a caller can drop the poll loop and resume later with just the id. A bare runner has no run store and `404`s these routes, which the client translates into a clear `RunLifecycleUnavailableError`. + +### Owned types (brand boundary) + +`pipelex_sdk/runs.py` **owns** the lifecycle models — they are a Pipelex-branded surface, mirroring `pipelex-sdk-js/src/runs.ts`. They are not imported from `mthds`. During the transition the same shapes still exist in `mthds-python`; that duplication is deliberate (so this SDK is correct regardless of `mthds-python`'s state) and is removed from `mthds-python` later. While the base `MthdsAPIClient` still declares the same lifecycle methods, the client's overrides return this package's own types and so read as incompatible overrides to the type-checker — they carry a narrow `# type: ignore[override]`, which becomes unnecessary (harmless) once the base copies are stripped. + +- `RunStatus` — the hosted status enum, with `is_terminal` / `is_success` predicates (exhaustive `match`). +- `RunRead` — a run record read through the self-healing status path (adds `degraded` + `retry_after_seconds`). +- `RunResults` — result artifacts. Hosted runs carry `main_stuff` (+ `graph_spec`); the bare-runner blocking fallback carries `pipe_output` (the runner's native execute response). Consumers read `main_stuff or pipe_output` (the documented hosted/bare output-shape difference). Extension-open, so any other server artifact is preserved. +- `RunResultState` — the single-shot result outcome, a union discriminated on `state` (`running` / `completed` / `failed`). +- `WaitForResultOptions` / `PollInfo` — poll-loop tuning and progress info. Async-native cancellation is via `asyncio.CancelledError` (cancel the awaiting task), so there is no `signal` field. + +### Polling surface + +- **`get_run_status(run_id)`** — `GET /v1/runs/{id}/status` → `RunRead`. Lifts the `Retry-After` header onto `retry_after_seconds`. +- **`get_run_result(run_id)`** — `GET /v1/runs/{id}/results`, mapping the platform's poll semantics to the `RunResultState` union: `202`/`503` → `running` (in-flight / degraded — never fail a poller), `200` → `completed`, `409` → `failed` (terminal status parsed from the message). +- **`wait_for_result(run_id, options)`** — polls `get_run_result` to a terminal state, honoring `Retry-After` and the deadline. Resolves on `COMPLETED`; raises `RunFailedError` on any other terminal status and `RunTimeoutError` if the budget elapses (the run keeps executing server-side — resume later by id). + +These poll GETs go through `_send_or_unreachable`, so a transport failure surfaces as `ApiUnreachableError` (consistent with the product layer), while a missing-route `404` surfaces as `RunLifecycleUnavailableError` and any other non-2xx as `httpx.HTTPStatusError`. + +### `start_and_wait` — hosted ↔ bare self-healing + +`start_and_wait` runs the whole lifecycle in one call and self-heals across runner kinds (the one place this SDK intentionally exceeds `mthds-python`, whose `start_and_wait` raises on a bare runner): + +- A cached `GET /v1/version` handshake (`_supports_run_lifecycle`) classifies the runner. `VersionInfo.implementation == "pipelex-api"` ⇒ a bare runner (no run store); anything else ⇒ assumed hosted. The outcome is cached for the client's lifetime; a failed handshake assumes hosted and lets `start` surface the real error. +- **Hosted:** durable `start` (202 ack) → `wait_for_result` (poll to terminal). +- **Bare runner:** the blocking `POST /v1/execute` (`_execute_blocking`), which has no gateway cap off-platform and returns the native `pipe_output`, mapped onto `RunResults` (`main_stuff = None`). +- **Self-heal:** a runner can look hosted yet lack the durable routes (`implementation` is an optional extension a compliant bare runner may omit). The client's `start` override translates a bare-runner missing-route `404` into `RunLifecycleUnavailableError` — raised **before any run is created** — so `start_and_wait` falls back to the blocking path without risking a double-run, and caches the negative so later calls skip the durable attempt. + +### Run/lifecycle errors + +`RunFailedError`, `RunTimeoutError`, and `RunLifecycleUnavailableError` are owned in `pipelex_sdk/errors.py`. `RunStillRunningError` — the protocol `execute()` 202-degrade error — stays owned by `mthds` and is re-exported from `pipelex_sdk/errors.py` so consumers have a single import home for all run/lifecycle errors. + ## Out of scope for v0.1 - `/v1/build/*` helpers (the TS clients carry them; recorded as a conscious deferral). diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index f7dc0b1..22eb5f2 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -14,10 +14,13 @@ from __future__ import annotations +import asyncio import json import os -from typing import Any, NamedTuple, NoReturn, cast -from urllib.parse import urlparse +import re +import time +from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, cast +from urllib.parse import quote, urlparse import httpx from mthds.config.credentials import load_credentials @@ -28,13 +31,40 @@ from pydantic_core import to_json from typing_extensions import override -from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError +from pipelex_sdk.errors import ( + ApiResponseError, + ApiUnreachableError, + RunFailedError, + RunLifecycleUnavailableError, + RunTimeoutError, +) +from pipelex_sdk.runs import ( + PollInfo, + RunRead, + RunResultCompleted, + RunResultFailed, + RunResultRunning, + RunResults, + RunStatus, + WaitForResultOptions, +) + +if TYPE_CHECKING: + from mthds.protocol.models import RunResultStart + from mthds.protocol.pipe_output import VariableMultiplicity + from mthds.protocol.pipeline_inputs import PipelineInputs + from mthds.protocol.stuff import StuffType + from mthds.protocol.working_memory import WorkingMemoryAbstract + from mthds.runners.api.models import DictRunResultExecute + + from pipelex_sdk.runs import RunResultState # The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`. # The same paths are served by the Pipelex Hosted API (api.pipelex.com) and by a bare # OSS pipelex-api runner (localhost:8081) — the protocol surface is identical; only the # hosted extensions (e.g. run polling) differ, detectable via GET /v1/version. _API_PREFIX = "v1" +_RUNS = "runs" #: Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. DEFAULT_API_BASE_URL = "https://api.pipelex.com" @@ -46,6 +76,12 @@ _PIPELEX_API_KEY_ENV = "PIPELEX_API_KEY" _PIPELEX_API_URL_ENV = "PIPELEX_API_URL" +# `VersionInfo.implementation` of the bare open-source runner (no run store). Anything +# else — the hosted implementation first — is assumed to serve the durable run-lifecycle +# extension; a wrong guess still fails with a clear `RunLifecycleUnavailableError` on the +# first poll (or self-heals through `start_and_wait`'s blocking-execute fallback). +_BARE_RUNNER_IMPLEMENTATION = "pipelex-api" + class PipelexAPIClient(MthdsAPIClient): """Client for the Pipelex hosted API — and any MTHDS-compliant runner. @@ -92,6 +128,8 @@ def __init__(self, api_token: str | None = None, api_base_url: str | None = None #: Origin root derived from the base URL — `/health` lives here, not under `/v1`. self.origin_url: str = _origin_of(normalized_base_url) self.client: httpx.AsyncClient | None = None + #: Cached `/v1/version` handshake outcome — whether the durable lifecycle is served. + self._lifecycle_available: bool | None = None @override def start_client(self) -> PipelexAPIClient: @@ -167,10 +205,362 @@ def _raise_api_response_error(self, *, method: str, endpoint: str, response: htt code=parsed.code, ) + @override + def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) -> None: + """Translate a "route absent" 404 (a bare pipelex-api with no platform block) into a clear + `RunLifecycleUnavailableError`. The platform's own 404s (run not found / cross-org) carry a + structured problem+json envelope (a `code` field) and are left for normal handling. + """ + if response.status_code != 404: + return + if _is_missing_route_404(response): + msg = ( + f"The durable run lifecycle is not available: {url} returned 404. Run polling is a " + f"hosted-API extension (/{_API_PREFIX}/{_RUNS}/*), not part of the MTHDS Protocol; " + "PIPELEX_API_URL points at a bare runner that does not serve it." + ) + raise RunLifecycleUnavailableError(msg, api_url=self.api_base_url) + + # ── Protocol surface: `start` override (bare-runner 404 → typed error) ── + + @override + async def start( + self, + pipe_code: str | None = None, + mthds_contents: list[str] | None = None, + inputs: PipelineInputs | WorkingMemoryAbstract[StuffType] | None = None, + output_name: str | None = None, + output_multiplicity: VariableMultiplicity | None = None, + dynamic_output_concept_ref: str | None = None, + extra: dict[str, Any] | None = None, + ) -> RunResultStart: + """Start a method asynchronously — `POST /v1/start` (202: `pipeline_run_id` only). + + Identical to the inherited protocol `start`, except a bare-runner missing-route 404 + (no run store) is translated into a clear `RunLifecycleUnavailableError` instead of a + raw `httpx.HTTPStatusError` — matching the JS SDK and letting `start_and_wait` self-heal + to the blocking-execute fallback. The platform's structured 404s (run not found) keep + their normal `httpx.HTTPStatusError` behavior. + """ + try: + return await super().start( + pipe_code=pipe_code, + mthds_contents=mthds_contents, + inputs=inputs, + output_name=output_name, + output_multiplicity=output_multiplicity, + dynamic_output_concept_ref=dynamic_output_concept_ref, + extra=extra, + ) + except httpx.HTTPStatusError as exc: + self._raise_if_lifecycle_unavailable(exc.response, str(exc.request.url)) + raise + + # ── Hosted extension: durable run lifecycle (NOT part of the protocol) ── + # + # These four methods are OWNED by this SDK: they return this package's own `runs` + # types (a Pipelex-branded surface), which are nominally distinct from the same-shaped + # types still in `mthds` during the transition. While the base `MthdsAPIClient` also + # declares them (until HANDOFF Phase 6 strips them), they read as incompatible overrides + # to the type-checker — hence `# type: ignore[override]`. Phase 6 deletes the base copies, + # after which the suppressions become unnecessary (harmless) and the `@override` markers + # should be removed alongside the base methods. + + @override + async def get_run_status(self, run_id: str) -> RunRead: # type: ignore[override] + """Fetch a run's status by bare id — `GET /v1/runs/{run_id}/status`. + + Self-healing: a finished-but-unrecorded run resolves to its true terminal status on read. + `degraded=True` means Temporal was unreachable and `status` is the last-known value; + `retry_after_seconds` carries the server's `Retry-After` hint when present. + + Raises: + RunLifecycleUnavailableError: If the lifecycle routes are absent (a bare runner). + ApiUnreachableError: If the host cannot be reached (DNS / connect / TLS / timeout). + httpx.HTTPStatusError: For a genuine run-not-found 404 or any other non-2xx response. + """ + url = self._url(f"{_RUNS}/{quote(run_id, safe='')}/status") + response = await self._send_or_unreachable("GET", url, content=None, request_timeout=_POLL_REQUEST_TIMEOUT_SECONDS) + self._raise_if_lifecycle_unavailable(response, url) + response.raise_for_status() + run = RunRead.model_validate(response.json()) + retry_after = _parse_retry_after(response.headers) + if retry_after is not None: + run = run.model_copy(update={"retry_after_seconds": retry_after}) + return run + + @override + async def get_run_result(self, run_id: str) -> RunResultState: # type: ignore[override] + """Single-shot result lookup — `GET /v1/runs/{run_id}/results`. + + Maps the platform's poll semantics to a discriminated union: + - HTTP 202 → `running` (in-flight, with the `Retry-After` hint) + - HTTP 503 → `running` (DynamoDB/Temporal degraded — retry, never fail a poller) + - HTTP 200 → `completed` (with the result artifacts) + - HTTP 409 → `failed` (terminal non-`COMPLETED`) + + Raises: + RunLifecycleUnavailableError: If the lifecycle routes are absent (a bare runner). + ApiUnreachableError: If the host cannot be reached (DNS / connect / TLS / timeout). + httpx.HTTPStatusError: For a genuine run-not-found 404 or any other non-2xx response. + """ + url = self._url(f"{_RUNS}/{quote(run_id, safe='')}/results") + response = await self._send_or_unreachable("GET", url, content=None, request_timeout=_POLL_REQUEST_TIMEOUT_SECONDS) + status_code = response.status_code + + if status_code in {202, 503}: + retry_after = _parse_retry_after(response.headers) + return RunResultRunning( + pipeline_run_id=run_id, + retry_after_seconds=retry_after if retry_after is not None else _DEFAULT_DEGRADED_RETRY_SECONDS, + ) + if status_code == 409: + message = _parse_error_message(response) or "Run finished without a result." + return RunResultFailed( + pipeline_run_id=run_id, + status=_extract_run_status_from_message(message), + message=message, + ) + + self._raise_if_lifecycle_unavailable(response, url) + response.raise_for_status() + result = RunResults.model_validate(response.json()) + return RunResultCompleted(pipeline_run_id=run_id, result=result) + + @override + async def wait_for_result(self, run_id: str, options: WaitForResultOptions | None = None) -> RunResults: # type: ignore[override] + """Poll a run to a terminal state and return its result. + + Resolves on `COMPLETED`, raises `RunFailedError` on any other terminal status, and raises + `RunTimeoutError` if `timeout_seconds` elapses first (the run keeps executing server-side — + resume later by `run_id`). Honors the server's `Retry-After`. Async-native: cancelling the + awaiting task raises `asyncio.CancelledError` out of this loop, leaving the run resumable. + """ + opts = options or WaitForResultOptions() + started_at = time.monotonic() + attempt = 0 + + while True: + elapsed = time.monotonic() - started_at + remaining = opts.timeout_seconds - elapsed + if remaining <= 0: + raise RunTimeoutError(_timeout_message(run_id, opts.timeout_seconds), run_id=run_id, timeout_seconds=opts.timeout_seconds) + + try: + state = await asyncio.wait_for(self.get_run_result(run_id), timeout=remaining) + except asyncio.TimeoutError as exc: # noqa: UP041 — on Python 3.10 asyncio.TimeoutError is its own class, distinct from builtin TimeoutError. + raise RunTimeoutError(_timeout_message(run_id, opts.timeout_seconds), run_id=run_id, timeout_seconds=opts.timeout_seconds) from exc + + if isinstance(state, RunResultCompleted): + return state.result + if isinstance(state, RunResultFailed): + msg = state.message + raise RunFailedError(msg, run_id=run_id, status=state.status) + + # state is RunResultRunning — decide whether to keep waiting. + attempt += 1 + elapsed = time.monotonic() - started_at + if elapsed >= opts.timeout_seconds: + raise RunTimeoutError(_timeout_message(run_id, opts.timeout_seconds), run_id=run_id, timeout_seconds=opts.timeout_seconds) + if opts.on_poll is not None: + opts.on_poll(PollInfo(attempt=attempt, elapsed_seconds=elapsed)) + + retry_seconds = state.retry_after_seconds if state.retry_after_seconds is not None else 0 + wait_seconds = min(max(opts.interval_seconds, retry_seconds), opts.timeout_seconds - elapsed) + await asyncio.sleep(wait_seconds) + + async def _supports_run_lifecycle(self) -> bool: + """Whether the configured server serves the durable run lifecycle, decided via the + `GET /v1/version` handshake and cached for the client's lifetime. A bare `pipelex-api` + runner has no run store; anything else is assumed hosted. When the handshake itself fails, + assume hosted (the SDK default) and let the start call surface the real error. + """ + if self._lifecycle_available is None: + try: + info = await self.version() + except (httpx.HTTPError, ValidationError): + self._lifecycle_available = True + else: + implementation = (info.model_extra or {}).get("implementation") + self._lifecycle_available = not (isinstance(implementation, str) and implementation == _BARE_RUNNER_IMPLEMENTATION) + return self._lifecycle_available + + @override + async def start_and_wait( # type: ignore[override] + self, + pipe_code: str | None = None, + mthds_contents: list[str] | None = None, + inputs: PipelineInputs | WorkingMemoryAbstract[StuffType] | None = None, + output_name: str | None = None, + output_multiplicity: VariableMultiplicity | None = None, + dynamic_output_concept_ref: str | None = None, + extra: dict[str, Any] | None = None, + wait_options: WaitForResultOptions | None = None, + ) -> RunResults: + """Start a run and wait for its result — the whole lifecycle in one call, self-healing + across hosted and bare runners. + + - **Hosted** (per the `/v1/version` handshake): durable `start` + poll, the path that + survives the gateway's ~30s synchronous ceiling and client disconnects. + - **Bare runner** (no run store): the blocking `POST /v1/execute`, which has no gateway + cap off-platform and returns the native `pipe_output`. + + A runner can look hosted yet lack the durable routes (`implementation` is an extension + field a compliant bare runner may omit). Such a runner raises `RunLifecycleUnavailableError` + from `start`, BEFORE any run is created, so the blocking fallback cannot double-run; the + negative is cached so later calls skip the durable attempt. + + Raises: + RunFailedError: If the run reaches a terminal status other than COMPLETED. + RunTimeoutError: If the poll budget elapses (the run keeps executing — resume by id). + """ + if await self._supports_run_lifecycle(): + try: + started = await self.start( + pipe_code=pipe_code, + mthds_contents=mthds_contents, + inputs=inputs, + output_name=output_name, + output_multiplicity=output_multiplicity, + dynamic_output_concept_ref=dynamic_output_concept_ref, + extra=extra, + ) + except RunLifecycleUnavailableError: + self._lifecycle_available = False + return await self._execute_blocking( + pipe_code=pipe_code, + mthds_contents=mthds_contents, + inputs=inputs, + output_name=output_name, + output_multiplicity=output_multiplicity, + dynamic_output_concept_ref=dynamic_output_concept_ref, + extra=extra, + ) + return await self.wait_for_result(started.pipeline_run_id, options=wait_options) + + return await self._execute_blocking( + pipe_code=pipe_code, + mthds_contents=mthds_contents, + inputs=inputs, + output_name=output_name, + output_multiplicity=output_multiplicity, + dynamic_output_concept_ref=dynamic_output_concept_ref, + extra=extra, + ) + + async def _execute_blocking( + self, + *, + pipe_code: str | None, + mthds_contents: list[str] | None, + inputs: PipelineInputs | WorkingMemoryAbstract[StuffType] | None, + output_name: str | None, + output_multiplicity: VariableMultiplicity | None, + dynamic_output_concept_ref: str | None, + extra: dict[str, Any] | None, + ) -> RunResults: + """Blocking `POST /v1/execute` adapted onto `RunResults` — the bare-runner path. + + Forwards every protocol field PLUS the `extra` extension passthrough: an extension-only + call (`{extra}` with no pipe_code/bundle) or a vendor selector riding `extra` must survive + this path, not just the durable one. + """ + result = await self.execute( + pipe_code=pipe_code, + mthds_contents=mthds_contents, + inputs=inputs, + output_name=output_name, + output_multiplicity=output_multiplicity, + dynamic_output_concept_ref=dynamic_output_concept_ref, + extra=extra, + ) + return _map_run_result_to_run_results(result) + # ── Module helpers ────────────────────────────────────────────────────── +_KNOWN_RUN_STATUS_NAMES: frozenset[str] = frozenset(RunStatus.__members__) + + +def _timeout_message(run_id: str, timeout_seconds: float) -> str: + """The shared `RunTimeoutError` message — the run survives and is resumable by id.""" + return f"Run {run_id} did not reach a terminal state within {timeout_seconds}s; it is still executing server-side and can be resumed by id." + + +def _is_missing_route_404(response: httpx.Response) -> bool: + """Whether a 404 is an unmatched-route 404 (no platform deployed) rather than the platform's + structured run-not-found 404. The platform wraps its 404s in RFC 7807 problem+json with a stable + `code`; a bare runner returns Starlette's default `{"detail": "Not Found"}` (no `code`). + """ + try: + body = response.json() + except ValueError: + return True + if not isinstance(body, dict): + return True + return "code" not in body + + +def _parse_retry_after(headers: httpx.Headers) -> int | None: + """Parse the `Retry-After` header (integer-seconds form, which the platform uses).""" + raw = headers.get("retry-after") + if not raw: + return None + try: + seconds = int(raw) + except ValueError: + return None + return seconds if seconds >= 0 else None + + +def _parse_error_message(response: httpx.Response) -> str | None: + """Extract a human message from an error body — handles the platform's problem+json (`detail` + string) and the runner's `{"detail": {"message": ...}}` / `{"message": ...}` shapes. + """ + try: + raw = response.json() + except ValueError: + return None + if not isinstance(raw, dict): + return None + body = cast("dict[str, Any]", raw) + detail = body.get("detail") + if isinstance(detail, str): + return detail + if isinstance(detail, dict): + message = cast("dict[str, Any]", detail).get("message") + if isinstance(message, str): + return message + top_message = body.get("message") + return top_message if isinstance(top_message, str) else None + + +def _extract_run_status_from_message(message: str) -> RunStatus: + """Pull the status word out of a 409 detail ("Run finished with status FAILED; ..."), defaulting + to FAILED if the shape ever changes. + """ + match = re.search(r"status\s+([A-Z_]+)", message) + if match and match.group(1) in _KNOWN_RUN_STATUS_NAMES: + return RunStatus(match.group(1)) + return RunStatus.FAILED + + +def _map_run_result_to_run_results(response: DictRunResultExecute) -> RunResults: + """Map the protocol's blocking `POST /v1/execute` response onto the lifecycle's `RunResults`. + + The bare-runner path returns `pipe_output` (native runner shape); `main_stuff` and `graph_spec` + are hosted-durable artifacts and stay `None` here. Consumers read `main_stuff or pipe_output` + (the documented hosted/bare output-shape difference). + """ + return RunResults( + pipeline_run_id=response.pipeline_run_id, + main_stuff=None, + graph_spec=None, + pipe_output=response.pipe_output.model_dump(), + ) + + def _is_valid_base_url(value: str) -> bool: """Whether a base URL is host-only — http/https, no path, query, fragment, or embedded credentials (auth travels in the Authorization header, never the URL). diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index 8b38a35..4e80216 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -12,9 +12,10 @@ a consumer branches on (decoupled from the HTTP status). The run-lifecycle errors (`RunFailedError`, `RunTimeoutError`, -`RunLifecycleUnavailableError`) are added here in Phase 2; `RunStillRunningError` -stays in `mthds` (it belongs to the protocol `execute()` 202-degrade path) and is -imported by consumers from there. +`RunLifecycleUnavailableError`) are owned here (ported from `mthds-python` in +HANDOFF Phase 2, and removed from `mthds-python` in Phase 6). `RunStillRunningError` +stays in `mthds` — it belongs to the protocol `execute()` 202-degrade path, not the +lifecycle — and is re-exported here so consumers have a single import home. """ from __future__ import annotations @@ -23,9 +24,15 @@ from mthds.protocol.exceptions import PipelineRequestError +# Explicit re-export (PEP 484 `as` self-alias): the protocol 202-degrade error stays owned by +# `mthds`, surfaced here so consumers have a single import home for the run/lifecycle errors. +from mthds.runners.api.exceptions import RunStillRunningError as RunStillRunningError # noqa: PLC0414 + if TYPE_CHECKING: from mthds.runners.api.models import ValidationErrorItem + from pipelex_sdk.runs import RunStatus + class ApiUnreachableError(PipelineRequestError): """Raised when the Pipelex API host cannot be reached at all. @@ -78,3 +85,47 @@ def __init__( self.server_message = server_message self.validation_errors = validation_errors self.code = code + + +class RunFailedError(PipelineRequestError): + """Raised when a run reaches a terminal state that is not `COMPLETED`. + + Surfaced from `wait_for_result` / `get_run_result` when the platform answers a + result lookup with HTTP 409 (`FAILED`, `CANCELLED`, `TERMINATED`, + `TIMED_OUT`). `run_id` and `status` let callers report the outcome precisely; + `status` stays the typed `RunStatus` enum so callers can match/case on it. + """ + + def __init__(self, message: str, run_id: str, status: RunStatus) -> None: + super().__init__(message) + self.run_id = run_id + self.status = status + + +class RunTimeoutError(PipelineRequestError): + """Raised when `wait_for_result` exceeds its timeout before the run is terminal. + + The run is NOT cancelled — it keeps executing server-side and can be resumed + later by `run_id` (the poll loop just stopped waiting). + """ + + def __init__(self, message: str, run_id: str, timeout_seconds: float) -> None: + super().__init__(message) + self.run_id = run_id + self.timeout_seconds = timeout_seconds + + +class RunLifecycleUnavailableError(PipelineRequestError): + """Raised when the durable run lifecycle (`/v1/runs/*`) is not served by the + configured `PIPELEX_API_URL`. + + Run polling is a hosted-API extension, not part of the MTHDS Protocol: the + open-source `pipelex-api` runner executes methods but has no run store, so it + 404s those routes; only a deployment that includes the platform block (the + Pipelex Hosted API) serves status/results. Distinguished from a genuine + run-not-found 404, which carries the platform's structured error envelope. + """ + + def __init__(self, message: str, api_url: str) -> None: + super().__init__(message) + self.api_url = api_url diff --git a/pipelex_sdk/runs.py b/pipelex_sdk/runs.py new file mode 100644 index 0000000..c897f9b --- /dev/null +++ b/pipelex_sdk/runs.py @@ -0,0 +1,198 @@ +"""Run-lifecycle models for the hosted polling surface (`/v1/runs/*`). + +Long method runs outlive the hosted gateway's ~30s synchronous cap, so the SDK +submits a run (`POST /v1/start`), then polls a self-healing endpoint by bare +`pipeline_run_id` until the run reaches a terminal state. All state lives behind the id +(DynamoDB + Temporal on the platform), so a caller can drop the poll loop and +resume later with just the id. + +Polling is NOT part of the MTHDS Protocol — it is a hosted-API extension. A +bare runner 404s these routes, which the client translates into +`RunLifecycleUnavailableError`. + +These types are **owned by this SDK** (not imported from `mthds`): the run +lifecycle is a Pipelex-branded hosted surface, mirroring `pipelex-sdk-js/src/runs.ts`. +During the transition (HANDOFF Phase 2) the same shapes still exist in +`mthds-python`; that duplication is deliberate and is removed from `mthds-python` +in Phase 6, leaving these as the single home. + +Wire contract mirrors `pipelex-platform`: + POST /v1/start -> RunResultStart (start, 202) + GET /v1/runs/{pipeline_run_id}/status -> RunRead (status, self-healing) + GET /v1/runs/{pipeline_run_id}/results -> 202 / 200 / 409 (results) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field + +from pipelex_sdk._compat import StrEnum + +if TYPE_CHECKING: + from collections.abc import Callable + + +# ── Status ────────────────────────────────────────────────────────── + + +class RunStatus(StrEnum): + """Hosted run lifecycle status. Mirrors `pipelex_shared.schemas.run.RunStatus`. + + Run states are a hosted-implementation concept — the protocol defines none. + `STARTED` is deprecated server-side but kept here for historical rows. + """ + + PENDING = "PENDING" + STARTED = "STARTED" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + TERMINATED = "TERMINATED" + TIMED_OUT = "TIMED_OUT" + + @property + def is_terminal(self) -> bool: + """True if the run has reached a terminal state (no further transitions).""" + match self: + case RunStatus.COMPLETED | RunStatus.FAILED | RunStatus.CANCELLED | RunStatus.TERMINATED | RunStatus.TIMED_OUT: + return True + case RunStatus.PENDING | RunStatus.STARTED | RunStatus.RUNNING: + return False + + @property + def is_success(self) -> bool: + """True only for `COMPLETED`; every other terminal status is a failure.""" + match self: + case RunStatus.COMPLETED: + return True + case ( + RunStatus.PENDING + | RunStatus.STARTED + | RunStatus.RUNNING + | RunStatus.FAILED + | RunStatus.CANCELLED + | RunStatus.TERMINATED + | RunStatus.TIMED_OUT + ): + return False + + +# ── Responses ─────────────────────────────────────────────────────── + + +class RunPublic(BaseModel): + """A run record — the BASE shape of the run-lifecycle read surface. + + Only the base fields are declared here. An implementation may return more + (identity, workflow ids, storage URLs, anything else) — those are + server-specific response fields, never named in this SDK. The model is + extension-open (`extra="allow"`): unknown fields are preserved and remain + accessible as attributes, mirroring the request-side `extra` passthrough. + """ + + model_config = ConfigDict(extra="allow") + + pipeline_run_id: str + pipe_code: str | None = None + status: RunStatus + created_at: str + finished_at: str | None = None + + +class RunRead(RunPublic): + """A run read through the self-healing path (`RunPublic` + `degraded`). + + When `degraded` is true, Temporal was unreachable and `status` is the + last-known DB value, not a freshly-derived one — pair with + `retry_after_seconds` (parsed from the `Retry-After` header by the client). + """ + + degraded: bool = False + retry_after_seconds: int | None = None + + +class RunResults(BaseModel): + """Result artifacts for a completed run — `GET /v1/runs/{pipeline_run_id}/results`. + + Hosted: `main_stuff` + `graph_spec` (S3 artifacts relayed VERBATIM; + `main_stuff` is polymorphic — a list output renders to a top-level array, a + structured output to an object — so both are typed as opaque JSON (`Any`), + never `dict`; either may be `None` mid-write). Bare-runner blocking fallback: + the runner's native execute response rides `pipe_output`. Consumers read + `main_stuff or pipe_output` (the documented hosted/bare output-shape + difference). Extension-open (`extra="allow"`): any other server artifact is + preserved without being named by the SDK. + """ + + model_config = ConfigDict(extra="allow") + + pipeline_run_id: str + #: Method graph spec (`graphspec.json`); `None` if missing mid-write or on the bare-runner path. + graph_spec: Any = None + #: Main output stuff (`main_stuff.json`); `None` if missing mid-write or on the bare-runner path. + main_stuff: Any = None + #: Bare runner's native pipe output (blocking-execute fallback only); `None` on the hosted path. + pipe_output: dict[str, Any] | None = None + + +# ── Single-shot result lookup outcome (discriminated on `state`) ───── + + +class RunResultRunning(BaseModel): + """HTTP 202 — the run is in-flight; poll again after `retry_after_seconds`.""" + + state: Literal["running"] = "running" + pipeline_run_id: str + retry_after_seconds: int | None = None + + +class RunResultCompleted(BaseModel): + """HTTP 200 — the run is `COMPLETED`; `result` carries the artifacts.""" + + state: Literal["completed"] = "completed" + pipeline_run_id: str + result: RunResults + + +class RunResultFailed(BaseModel): + """HTTP 409 — the run reached a terminal non-`COMPLETED` status.""" + + state: Literal["failed"] = "failed" + pipeline_run_id: str + status: RunStatus + message: str + + +RunResultState: TypeAlias = Annotated[ + RunResultRunning | RunResultCompleted | RunResultFailed, + Field(discriminator="state"), +] + + +# ── Polling options ───────────────────────────────────────────────── + + +@dataclass(frozen=True) +class PollInfo: + """Progress info handed to a `WaitForResultOptions.on_poll` callback before each sleep.""" + + attempt: int + elapsed_seconds: float + + +@dataclass +class WaitForResultOptions: + """Tuning for `wait_for_result`'s poll loop. + + The client is async-native: cancellation is via `asyncio.CancelledError` + (the Python analog of mthds-js's `AbortSignal`), so there is no `signal` + field — cancel the awaiting task instead. + """ + + interval_seconds: float = 2.0 + timeout_seconds: float = 1200.0 + on_poll: Callable[[PollInfo], None] | None = None diff --git a/tests/unit/test_client_lifecycle.py b/tests/unit/test_client_lifecycle.py new file mode 100644 index 0000000..346915f --- /dev/null +++ b/tests/unit/test_client_lifecycle.py @@ -0,0 +1,249 @@ +"""Tests for `PipelexAPIClient`'s durable run-lifecycle surface (start/status/results/wait), httpx mocked.""" + +import asyncio + +import httpx +import pytest +from mthds.protocol.exceptions import PipelineRequestError +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ( + RunFailedError, + RunLifecycleUnavailableError, + RunStillRunningError, + RunTimeoutError, +) +from pipelex_sdk.runs import ( + PollInfo, + RunResultCompleted, + RunResultFailed, + RunResultRunning, + RunResults, + RunStatus, + WaitForResultOptions, +) + +_BASE_URL = "http://localhost:8081" + + +def _response(status_code: int, *, json: object = None, headers: dict[str, str] | None = None) -> httpx.Response: + """Build a constructed httpx.Response with a request attached (so raise_for_status works).""" + request = httpx.Request("GET", f"{_BASE_URL}/x") + if json is None: + return httpx.Response(status_code, headers=headers or {}, request=request) + return httpx.Response(status_code, json=json, headers=headers or {}, request=request) + + +class TestClientLifecycle: + @pytest.fixture(autouse=True) + def _mock_credentials(self, mocker: MockerFixture) -> None: + """Keep construction hermetic — never touch the real credentials file/env.""" + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + + # ── start (inherited body-building + bare-runner 404 translation) ── + + def test_start_targets_v1_url_and_returns_run_result_start(self, mocker: MockerFixture) -> None: + """Start posts to /v1/start; a 202 parses into RunResultStart with the authoritative id.""" + client = PipelexAPIClient(api_token="t", api_base_url=f"{_BASE_URL}/") + body = {"pipeline_run_id": "run_1", "state": "RUNNING", "created_at": "2026-06-10T00:00:00Z"} + send_mock = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(202, json=body))) + + started = asyncio.run(client.start(pipe_code="answer")) + assert client.api_base_url == _BASE_URL + assert send_mock.call_args.args[1] == f"{_BASE_URL}/v1/start" + assert started.pipeline_run_id == "run_1" + + def test_start_request_prunes_absent_fields_and_carries_extra(self, mocker: MockerFixture) -> None: + """Absent fields are pruned (exclude_none); extension args ride the body as top-level properties.""" + client = self._client() + body = {"pipeline_run_id": "run_1", "state": "RUNNING", "created_at": "2026-06-10T00:00:00Z"} + send_mock = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(202, json=body))) + + asyncio.run(client.start(pipe_code="answer", extra={"some_vendor_arg": {"nested": True}})) + sent = send_mock.call_args.kwargs["content"].decode("utf-8") + assert '"pipe_code":"answer"' in sent + assert '"some_vendor_arg":{"nested":true}' in sent + assert "output_name" not in sent + + def test_start_extra_rejects_protocol_args(self) -> None: + """`extra` is for extension args only — a protocol arg inside it raises a clear client-side error + (raised by the inherited body-builder, before any request, so the override passes it through). + """ + client = self._client() + with pytest.raises(PipelineRequestError, match="pipe_code"): + asyncio.run(client.start(mthds_contents=['domain = "answer"'], extra={"pipe_code": "smuggled"})) + + def test_start_bare_runner_missing_route_404_is_lifecycle_unavailable(self, mocker: MockerFixture) -> None: + """A bare-runner 404 with Starlette's default body (no `code`) becomes RunLifecycleUnavailableError.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json={"detail": "Not Found"}))) + + with pytest.raises(RunLifecycleUnavailableError) as exc_info: + asyncio.run(client.start(pipe_code="answer")) + assert exc_info.value.api_url == _BASE_URL + + def test_start_structured_404_stays_http_status_error(self, mocker: MockerFixture) -> None: + """A structured platform 404 (carries `code`) is a normal HTTP error, not lifecycle-unavailable.""" + client = self._client() + body = {"code": "NOT_FOUND", "detail": "The requested resource does not exist."} + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json=body))) + + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(client.start(pipe_code="answer")) + + # ── get_run_status ─────────────────────────────────────────── + + def test_get_run_status_populates_degraded_and_retry_after(self, mocker: MockerFixture) -> None: + """get_run_status hits /v1/runs/{id}/status, parses RunRead, and lifts Retry-After.""" + client = self._client() + body = {"pipeline_run_id": "run_1", "status": "RUNNING", "created_at": "2026-06-10T00:00:00Z", "degraded": True} + send_mock = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json=body, headers={"Retry-After": "7"}))) + + run = asyncio.run(client.get_run_status("run_1")) + assert send_mock.call_args.args[1] == f"{_BASE_URL}/v1/runs/run_1/status" + assert run.degraded is True + assert run.retry_after_seconds == 7 + + def test_get_run_status_lifecycle_unavailable_on_missing_route(self, mocker: MockerFixture) -> None: + """A bare-runner 404 on the status route becomes RunLifecycleUnavailableError.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json={"detail": "Not Found"}))) + + with pytest.raises(RunLifecycleUnavailableError): + asyncio.run(client.get_run_status("run_1")) + + # ── get_run_result status mapping ──────────────────────────── + + def test_get_run_result_completed_keeps_polymorphic_main_stuff(self, mocker: MockerFixture) -> None: + """A 200 maps to RunResultCompleted; a list main_stuff stays a top-level array; graph_spec is parsed.""" + client = self._client() + body: dict[str, object] = { + "pipeline_run_id": "run_1", + "main_stuff": [{"color": "red"}, {"color": "blue"}], + "graph_spec": {"nodes": []}, + } + send_mock = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert send_mock.call_args.args[1] == f"{_BASE_URL}/v1/runs/run_1/results" + assert isinstance(state, RunResultCompleted) + assert state.result.main_stuff == [{"color": "red"}, {"color": "blue"}] + assert state.result.graph_spec == {"nodes": []} + + def test_get_run_result_running_honors_retry_after(self, mocker: MockerFixture) -> None: + """A 202 maps to RunResultRunning with the server's Retry-After hint.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(202, headers={"Retry-After": "3"}))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultRunning) + assert state.retry_after_seconds == 3 + + def test_get_run_result_degraded_503_defaults_retry(self, mocker: MockerFixture) -> None: + """A 503 (DynamoDB/Temporal degraded) maps to running with the default retry, never a failure.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(503))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultRunning) + assert state.retry_after_seconds == 5 + + def test_get_run_result_failed_extracts_status(self, mocker: MockerFixture) -> None: + """A 409 maps to RunResultFailed with the terminal status parsed from the detail message.""" + client = self._client() + body = {"code": "CONFLICT", "detail": "Run finished with status TIMED_OUT; no result available"} + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultFailed) + assert state.status == RunStatus.TIMED_OUT + assert "TIMED_OUT" in state.message + + def test_get_run_result_lifecycle_unavailable_on_missing_route(self, mocker: MockerFixture) -> None: + """A bare-runner 404 on the results route becomes RunLifecycleUnavailableError.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json={"detail": "Not Found"}))) + + with pytest.raises(RunLifecycleUnavailableError): + asyncio.run(client.get_run_result("run_1")) + + # ── execute 202 degrade → re-exported RunStillRunningError ──── + + def test_execute_202_raises_re_exported_still_running(self, mocker: MockerFixture) -> None: + """A 202 on execute raises RunStillRunningError (re-exported from mthds) carrying run_id + hints.""" + client = self._client() + body = {"pipeline_run_id": "run_1", "state": "RUNNING", "created_at": "2026-06-10T00:00:00Z"} + headers = {"Retry-After": "10", "Location": "/v1/runs/run_1/results"} + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(202, json=body, headers=headers))) + + with pytest.raises(RunStillRunningError) as exc_info: + asyncio.run(client.execute(pipe_code="answer")) + assert exc_info.value.run_id == "run_1" + assert exc_info.value.retry_after_seconds == 10 + + # ── wait_for_result poll loop ──────────────────────────────── + + def test_wait_for_result_polls_until_completed(self, mocker: MockerFixture) -> None: + """The loop polls past a running state and returns the completed result; on_poll fires per wait.""" + client = self._client() + result = RunResults(pipeline_run_id="run_1", main_stuff={"answer": "42"}) + mocker.patch.object( + client, + "get_run_result", + mocker.AsyncMock( + side_effect=[ + RunResultRunning(pipeline_run_id="run_1", retry_after_seconds=0), + RunResultCompleted(pipeline_run_id="run_1", result=result), + ] + ), + ) + mocker.patch("pipelex_sdk.client.asyncio.sleep", mocker.AsyncMock()) + polls: list[PollInfo] = [] + + returned = asyncio.run(client.wait_for_result("run_1", WaitForResultOptions(interval_seconds=0.0, on_poll=polls.append))) + assert returned.main_stuff == {"answer": "42"} + assert len(polls) == 1 + assert polls[0].attempt == 1 + + def test_wait_for_result_raises_run_failed(self, mocker: MockerFixture) -> None: + """A terminal non-COMPLETED state raises RunFailedError carrying the typed status.""" + client = self._client() + mocker.patch.object( + client, + "get_run_result", + mocker.AsyncMock(return_value=RunResultFailed(pipeline_run_id="run_1", status=RunStatus.CANCELLED, message="cancelled")), + ) + + with pytest.raises(RunFailedError) as exc_info: + asyncio.run(client.wait_for_result("run_1")) + assert exc_info.value.run_id == "run_1" + assert exc_info.value.status == RunStatus.CANCELLED + + def test_wait_for_result_times_out(self, mocker: MockerFixture) -> None: + """When the run never terminates and the timeout elapses, RunTimeoutError is raised (run survives).""" + client = self._client() + mocker.patch.object( + client, + "get_run_result", + mocker.AsyncMock(return_value=RunResultRunning(pipeline_run_id="run_1", retry_after_seconds=0)), + ) + + with pytest.raises(RunTimeoutError) as exc_info: + asyncio.run(client.wait_for_result("run_1", WaitForResultOptions(timeout_seconds=0.0))) + assert exc_info.value.run_id == "run_1" + assert exc_info.value.timeout_seconds == 0.0 + + def test_wait_for_result_propagates_cancellation(self, mocker: MockerFixture) -> None: + """Cancellation surfaces as asyncio.CancelledError (the loop never swallows it; run stays resumable).""" + client = self._client() + mocker.patch.object(client, "get_run_result", mocker.AsyncMock(side_effect=asyncio.CancelledError())) + + with pytest.raises(asyncio.CancelledError): + asyncio.run(client.wait_for_result("run_1")) diff --git a/tests/unit/test_client_run_fallback.py b/tests/unit/test_client_run_fallback.py new file mode 100644 index 0000000..3fb14f9 --- /dev/null +++ b/tests/unit/test_client_run_fallback.py @@ -0,0 +1,187 @@ +"""Tests for `start_and_wait`'s hosted/bare self-healing — the version handshake + blocking fallback. + +No equivalent exists in `mthds-python` (whose `start_and_wait` raises on a bare runner); this is the +SDK's own enhancement (`supports_run_lifecycle` + `execute_blocking`), mirroring `pipelex-sdk-js`. +""" + +import asyncio +from typing import Any + +import httpx +import pytest +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiUnreachableError, RunLifecycleUnavailableError + +_BASE_URL = "http://localhost:8081" + +_HOSTED_VERSION = {"protocol_version": "0.6.0", "implementation": "pipelex-hosted", "runner_version": "0.9.0"} +_BARE_VERSION = {"protocol_version": "0.6.0", "implementation": "pipelex-api", "runner_version": "1.2.3"} +# A spec-compliant runner may report only the protocol base fields — `implementation` is an +# optional extension. Such a base-only response cannot be classified by name; the client must +# discover the missing lifecycle at runtime (start 404s) and self-heal to the blocking path. +_BASE_ONLY_VERSION = {"protocol_version": "0.6.0", "runner_version": "9.9.9"} + +_EXECUTE_BODY: dict[str, object] = { + "pipeline_run_id": "run-x", + "pipe_output": {"working_memory": {"root": {}, "aliases": {}}, "pipeline_run_id": "run-x"}, +} + + +def _response(status_code: int, *, json: object = None, headers: dict[str, str] | None = None) -> httpx.Response: + request = httpx.Request("GET", f"{_BASE_URL}/x") + if json is None: + return httpx.Response(status_code, headers=headers or {}, request=request) + return httpx.Response(status_code, json=json, headers=headers or {}, request=request) + + +def _urls(send_mock: Any) -> list[str]: + """The URL (second positional arg) of every `_send` call, in order.""" + return [call.args[1] for call in send_mock.call_args_list] + + +class TestClientRunFallback: + @pytest.fixture(autouse=True) + def _mock_credentials(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + + # ── Hosted (durable start + poll) ──────────────────────────── + + def test_hosted_handshakes_then_starts_then_polls(self, mocker: MockerFixture) -> None: + """Version (hosted) → start (202) → results (200); the durable path maps the result verbatim.""" + client = self._client() + send = mocker.patch.object( + client, + "_send", + mocker.AsyncMock( + side_effect=[ + _response(200, json=_HOSTED_VERSION), + _response(202, json={"pipeline_run_id": "run-1", "state": "STARTED", "created_at": "t0"}), + _response(200, json={"pipeline_run_id": "run-1", "main_stuff": {"answer": 42}, "graph_spec": {"n": 1}}), + ] + ), + ) + + result = asyncio.run(client.start_and_wait(pipe_code="p", mthds_contents=["x"])) + assert result.pipeline_run_id == "run-1" + assert result.main_stuff == {"answer": 42} + assert result.graph_spec == {"n": 1} + assert _urls(send) == [f"{_BASE_URL}/v1/version", f"{_BASE_URL}/v1/start", f"{_BASE_URL}/v1/runs/run-1/results"] + + def test_caches_the_version_handshake_across_calls(self, mocker: MockerFixture) -> None: + """The /v1/version handshake is performed once and cached for the client's lifetime.""" + client = self._client() + send = mocker.patch.object( + client, + "_send", + mocker.AsyncMock( + side_effect=[ + _response(200, json=_HOSTED_VERSION), + _response(202, json={"pipeline_run_id": "r1", "state": "STARTED", "created_at": "t0"}), + _response(200, json={"pipeline_run_id": "r1", "main_stuff": {}}), + _response(202, json={"pipeline_run_id": "r2", "state": "STARTED", "created_at": "t1"}), + _response(200, json={"pipeline_run_id": "r2", "main_stuff": {}}), + ] + ), + ) + + asyncio.run(client.start_and_wait(pipe_code="p")) + asyncio.run(client.start_and_wait(pipe_code="p")) + assert _urls(send).count(f"{_BASE_URL}/v1/version") == 1 + + # ── Bare runner (blocking execute fallback) ────────────────── + + def test_bare_runner_falls_back_to_blocking_execute(self, mocker: MockerFixture) -> None: + """A bare runner (`implementation == pipelex-api`) skips start and runs the blocking POST /v1/execute.""" + client = self._client() + send = mocker.patch.object( + client, + "_send", + mocker.AsyncMock(side_effect=[_response(200, json=_BARE_VERSION), _response(200, json=_EXECUTE_BODY)]), + ) + + result = asyncio.run(client.start_and_wait(pipe_code="p", mthds_contents=["x"])) + assert result.pipeline_run_id == "run-x" + assert result.main_stuff is None + assert result.pipe_output == {"working_memory": {"root": {}, "aliases": {}}, "pipeline_run_id": "run-x"} + assert _urls(send) == [f"{_BASE_URL}/v1/version", f"{_BASE_URL}/v1/execute"] + + def test_fallback_forwards_extra_extension_args(self, mocker: MockerFixture) -> None: + """An `extra` extension arg rides the blocking execute body as a top-level field — not dropped.""" + client = self._client() + send = mocker.patch.object( + client, + "_send", + mocker.AsyncMock(side_effect=[_response(200, json=_BARE_VERSION), _response(200, json=_EXECUTE_BODY)]), + ) + + asyncio.run(client.start_and_wait(inputs={"topic": "demo"}, extra={"some_vendor_selector": "sel_123"})) + execute_call = send.call_args_list[1] + assert execute_call.args[1] == f"{_BASE_URL}/v1/execute" + assert '"some_vendor_selector":"sel_123"' in execute_call.kwargs["content"].decode("utf-8") + + def test_self_heals_when_base_only_version_hides_missing_run_store(self, mocker: MockerFixture) -> None: + """A base-only version looks hosted → start 404s (no run created) → fall back; the negative is cached.""" + client = self._client() + send = mocker.patch.object( + client, + "_send", + mocker.AsyncMock( + side_effect=[ + _response(200, json=_BASE_ONLY_VERSION), + _response(404, json={"detail": "Not Found"}), + _response(200, json=_EXECUTE_BODY), + _response(200, json={**_EXECUTE_BODY, "pipeline_run_id": "run-x2"}), + ] + ), + ) + + first = asyncio.run(client.start_and_wait(pipe_code="p")) + assert first.pipeline_run_id == "run-x" + assert _urls(send) == [f"{_BASE_URL}/v1/version", f"{_BASE_URL}/v1/start", f"{_BASE_URL}/v1/execute"] + + # Second call: negative cached — no version re-handshake, no start retry, straight to execute. + second = asyncio.run(client.start_and_wait(pipe_code="p")) + assert second.pipeline_run_id == "run-x2" + assert _urls(send) == [ + f"{_BASE_URL}/v1/version", + f"{_BASE_URL}/v1/start", + f"{_BASE_URL}/v1/execute", + f"{_BASE_URL}/v1/execute", + ] + + def test_handshake_failure_assumes_hosted(self, mocker: MockerFixture) -> None: + """When the /v1/version handshake itself fails, assume hosted and let start surface the real error.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(500, json={"detail": "boom"}))) + + # version 500 → assume hosted → start hits the same 500 → raise_for_status → HTTPStatusError. + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(client.start_and_wait(pipe_code="p")) + + def test_lifecycle_primitives_raise_unavailable_on_bare_404(self, mocker: MockerFixture) -> None: + """The poll primitives surface a clear RunLifecycleUnavailableError on the bare-runner 404.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json={"detail": "Not Found"}))) + + with pytest.raises(RunLifecycleUnavailableError): + asyncio.run(client.get_run_status("r")) + with pytest.raises(RunLifecycleUnavailableError): + asyncio.run(client.get_run_result("r")) + with pytest.raises(RunLifecycleUnavailableError): + asyncio.run(client.wait_for_result("r")) + + def test_unreachable_host_maps_to_api_unreachable_on_poll(self, mocker: MockerFixture) -> None: + """A transport failure on a lifecycle GET maps to ApiUnreachableError (the richer transport layer).""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=httpx.ConnectError("refused"))) + + with pytest.raises(ApiUnreachableError): + asyncio.run(client.get_run_result("r")) diff --git a/tests/unit/test_runs.py b/tests/unit/test_runs.py new file mode 100644 index 0000000..11c8bd2 --- /dev/null +++ b/tests/unit/test_runs.py @@ -0,0 +1,31 @@ +"""Tests for pipelex_sdk.runs — run-lifecycle models for the hosted polling surface.""" + +import pytest +from pydantic import TypeAdapter + +from pipelex_sdk.runs import RunStatus + + +class TestRuns: + @pytest.mark.parametrize( + ("status", "is_terminal", "is_success"), + [ + (RunStatus.PENDING, False, False), + (RunStatus.STARTED, False, False), + (RunStatus.RUNNING, False, False), + (RunStatus.COMPLETED, True, True), + (RunStatus.FAILED, True, False), + (RunStatus.CANCELLED, True, False), + (RunStatus.TERMINATED, True, False), + (RunStatus.TIMED_OUT, True, False), + ], + ) + def test_run_status_predicates(self, status: RunStatus, is_terminal: bool, is_success: bool) -> None: + """is_terminal / is_success classify every status correctly.""" + assert status.is_terminal is is_terminal + assert status.is_success is is_success + + def test_run_status_parses_from_string(self) -> None: + """A wire string parses into the enum.""" + adapter = TypeAdapter(RunStatus) + assert adapter.validate_python("TIMED_OUT") == RunStatus.TIMED_OUT From 5a4da2fdc914f273d8483d22213dd85f4a10e190 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:16:06 +0200 Subject: [PATCH 05/15] feat(phase-3): Pipelex product surface + validate override Add the hosted product routes as typed Pydantic v2 models (pipelex_sdk/product_models.py) and client methods via _request_product: user profile, methods CRUD, organizations, billing, Pipelex API keys, gateway inference key, onboarding, storage, and run records. Documented 409-conflict behaviors surface through ApiResponseError.code (conflict; pipelex_api_key_limit_reached). Override validate to inject render: ["markdown"] (so valid + invalid verdicts both carry rendered_markdown), accept a parallel mthds_sources array, and add validate_files (synthesizes deterministic inline:// source labels when any file carries a URI). The override delegates the wire call to the inherited base validate, keeping body-building and the protocol error regime shared. Tests port pipelex-sdk-js/tests/product.test.ts (verb+path+body per route, model round-trips with complete bodies, .code branching) plus validate override coverage. Gate green: ruff, pyright, mypy, pylint 10.00/10, tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BkphvaUvNJi6G613jq1q7j --- CHANGELOG.md | 2 + docs/architecture.md | 26 ++ pipelex_sdk/client.py | 272 +++++++++++++++++++- pipelex_sdk/product_models.py | 360 ++++++++++++++++++++++++++ tests/unit/test_client_product.py | 396 +++++++++++++++++++++++++++++ tests/unit/test_client_validate.py | 128 ++++++++++ 6 files changed, 1179 insertions(+), 5 deletions(-) create mode 100644 pipelex_sdk/product_models.py create mode 100644 tests/unit/test_client_product.py create mode 100644 tests/unit/test_client_validate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c3251..30db10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,5 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke - Durable run lifecycle (`pipelex_sdk/runs.py` + client methods): owned run-lifecycle models (`RunStatus`, `RunRead`, `RunResults`, the discriminated `RunResultState`, `WaitForResultOptions`, `PollInfo`) and the polling surface `get_run_status` / `get_run_result` / `wait_for_result`, mapping the platform's `202`/`503`/`200`/`409` results semantics to a typed union. - `start_and_wait` self-heals across hosted and bare runners: a cached `GET /v1/version` handshake picks the durable start+poll path on the hosted API and falls back to the blocking `POST /v1/execute` on a bare runner (including the case where a base-only version response hides a missing run store — `start` then surfaces `RunLifecycleUnavailableError` before any run is created). This closes a gap versus `mthds-python` (whose `start_and_wait` raises on a bare runner). - Lifecycle errors `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`; `RunStillRunningError` (the protocol `execute()` 202-degrade error) re-exported from `mthds` so all run/lifecycle errors share one import home. +- Pipelex product surface (`pipelex_sdk/product_models.py` + client methods): the hosted management routes — user profile (`get_me`), methods catalog CRUD (`list_methods` / `get_method` / `create_method` / `update_method` / `delete_method`), organizations (`list_memberships` / `create_organization` / `rename_organization`), billing (`get_subscription` / `list_plans` / `list_invoices` / `create_checkout` / `change_plan` / `get_billing_portal`), Pipelex API keys (`list_pipelex_api_keys` / `create_pipelex_api_key` / `revoke_pipelex_api_key` / `rotate_pipelex_api_key`), the gateway inference key (`create_gateway_api_key` / `get_gateway_api_key`), onboarding (`submit_onboarding`), storage (`resolve_storage_url` / `upload`), and run records (`list_runs` / `update_run`). Documented `409`-conflict behaviors surface through `ApiResponseError.code` (`change_plan` / `get_billing_portal` ⇒ `conflict`; `create_pipelex_api_key` ⇒ `pipelex_api_key_limit_reached`). +- `validate` override + `validate_files`: the Pipelex-API `/v1/validate` surface — always injects `render: ["markdown"]` (so valid and invalid verdicts both carry `rendered_markdown`), accepts a parallel `mthds_sources` array, and `validate_files` synthesizes deterministic `inline://` source labels when any file carries a URI. diff --git a/docs/architecture.md b/docs/architecture.md index 4f591d2..ec4f513 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,6 +100,32 @@ These poll GETs go through `_send_or_unreachable`, so a transport failure surfac `RunFailedError`, `RunTimeoutError`, and `RunLifecycleUnavailableError` are owned in `pipelex_sdk/errors.py`. `RunStillRunningError` — the protocol `execute()` 202-degrade error — stays owned by `mthds` and is re-exported from `pipelex_sdk/errors.py` so consumers have a single import home for all run/lifecycle errors. +## `validate` override (Pipelex-API presentation + sources) + +The protocol `validate` is **overridden** (not inherited) to add the two Pipelex-API extensions the bare protocol route doesn't carry, while keeping the inherited protocol error regime (a no-verdict non-2xx surfaces as `httpx.HTTPStatusError`, not `ApiResponseError` — the verdict itself is always a 200 discriminated on `is_valid`): + +- **Markdown render is always injected.** `validate(...)` adds `"markdown"` to the `render` list (de-duplicated, caller tokens first) so both a valid `PipelexValidationReport` and a produced `PipelexInvalidReport` carry `rendered_markdown`. Unknown render tokens are server-side lenient-ignored. +- **`mthds_sources`** is a named parameter (parallel to `mthds_contents`) threaded onto each diagnostic's `source`; sent only when provided. +- **`validate_files(files, …)`** takes `MthdsFile(content, uri?)` records. When any file carries a URI, every content gets a parallel source label — the named file's URI, or a deterministic `inline://file-N.mthds` for an unnamed sibling — so the server never sees a length-mismatched `mthds_sources`. + +The override delegates the wire call to the inherited base `validate` (passing `render` / `mthds_sources` through the protocol's `extra` extension passthrough), so the body-building and transport stay shared; only the Pipelex presentation/sources concerns live here. The validation models (`PipelexValidationResult` = `PipelexValidationReport | PipelexInvalidReport`, with `rendered_markdown`) are reused from `mthds` for now (the brand-layering follow-up #9 — they would eventually migrate here to fully mirror the JS boundary). + +## Pipelex product surface (hosted management routes) + +The hosted catalog/account routes the webapp drives (`pipelex_sdk/product_models.py` + the client's product methods). Every route rides the same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a typed `ApiResponseError` — **consumers branch on `.code`, never the HTTP status**. + +The wire models are snake_case Pydantic v2. Response models are extension-open (`extra="allow"`) so a newly-added server field is preserved, not rejected; input models name exactly what each route accepts. `PipelineRun.status` reuses the run-lifecycle `RunStatus`; `OrgRole`, `PipeStatus`, and the onboarding fields are `StrEnum`s. + +- **User profile** — `get_me()` → `UserProfile` (`GET /v1/me`). +- **Methods catalog** — `list_methods()` / `get_method(id)` / `create_method(MethodWriteInput)` / `update_method(id, MethodWriteInput)` (a rename is a changed `name`) / `delete_method(id)`. The id is path-encoded; an absent `input_data` is dropped from the write body. +- **Organizations** — `list_memberships()` → `MembershipsResponse` (memberships + active-org feature flags); `create_organization(name)` / `rename_organization(org_id, name)` → `Membership`. Organization *switch* is out of scope (a WorkOS session op, not a `/v1` route). +- **Billing** — `get_subscription()`, `list_plans()`, `list_invoices()`, `create_checkout(plan)`. `change_plan(plan)` and `get_billing_portal()` surface a **409 `conflict`** (`ApiResponseError.code`) when there is no subscription yet — start one via `create_checkout` first. +- **Pipelex API keys** — `list_pipelex_api_keys()`; `create_pipelex_api_key(label)` and `rotate_pipelex_api_key(id)` return the plaintext `api_key` **once**; `revoke_pipelex_api_key(id)`. Creation surfaces a **409 `pipelex_api_key_limit_reached`** when the per-account limit is hit. Rotation sends no body. +- **Gateway (LLM inference) key** — `create_gateway_api_key(promo_code)` **always sends a JSON body** (even with `promo_code=None` → `{"promo_code": null}`); the server 422s an empty body. `get_gateway_api_key()` → status (`gateway_api_key` is `None` until provisioned). +- **Onboarding** — `submit_onboarding(OnboardingSubmission)` (`POST /v1/onboarding/submit`, empty 2xx body); absent optional fields are dropped. +- **Storage** — `resolve_storage_url(uri)` → presigned URL; `upload(UploadInput)` → the stored file handle. +- **Run records** — `list_runs(method_id)` → `list[PipelineRun]` (the catalog-style list, distinct from the lifecycle status/result routes); `update_run(run_id, UpdateRunInput)` (admin/manual status patch, empty 2xx body). + ## Out of scope for v0.1 - `/v1/build/*` helpers (the TS clients carry them; recorded as a conscious deferral). diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 22eb5f2..a6dd0fe 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -7,9 +7,11 @@ the richer transport/error layer the product and lifecycle phases build on, and — in later phases — the durable run lifecycle, the product surface, and `health`. -This module currently holds Phase 1: construction, the transport extension helpers -(`_request_product`, `_request_json`, `_send_or_unreachable`), and the `problem+json` -error-body parser. Lifecycle and product methods land in Phases 2-4. +This module holds construction, the transport extension helpers (`_request_product`, +`_request_json`, `_send_or_unreachable`), the `problem+json` error-body parser, the +durable run lifecycle, the `validate` override (markdown-render injection + +`validate_files`), and the Pipelex product surface (methods, organizations, billing, +API keys, onboarding, storage, run records). `health` lands in Phase 4. """ from __future__ import annotations @@ -27,7 +29,7 @@ from mthds.protocol.exceptions import PipelineRequestError from mthds.runners.api.client import MthdsAPIClient from mthds.runners.api.models import ValidationErrorItem -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic_core import to_json from typing_extensions import override @@ -38,6 +40,25 @@ RunLifecycleUnavailableError, RunTimeoutError, ) +from pipelex_sdk.product_models import ( + BillingPortalResponse, + ChangePlanResponse, + CheckoutResponse, + GatewayApiKey, + GatewayApiKeyStatus, + InvoiceView, + Membership, + MembershipsResponse, + MethodData, + PipelexApiKeyCreated, + PipelexApiKeyList, + PipelineRun, + PlanView, + ResolvedStorageUrl, + SubscriptionResponse, + UploadedFile, + UserProfile, +) from pipelex_sdk.runs import ( PollInfo, RunRead, @@ -55,8 +76,14 @@ from mthds.protocol.pipeline_inputs import PipelineInputs from mthds.protocol.stuff import StuffType from mthds.protocol.working_memory import WorkingMemoryAbstract - from mthds.runners.api.models import DictRunResultExecute + from mthds.runners.api.models import DictRunResultExecute, PipelexValidationResult + from pipelex_sdk.product_models import ( + MethodWriteInput, + OnboardingSubmission, + UpdateRunInput, + UploadInput, + ) from pipelex_sdk.runs import RunResultState # The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`. @@ -82,6 +109,24 @@ # first poll (or self-heals through `start_and_wait`'s blocking-execute fallback). _BARE_RUNNER_IMPLEMENTATION = "pipelex-api" +# `validate` always asks the Pipelex API for the Markdown view so both a valid result and a +# produced validation-error verdict carry `rendered_markdown`; callers may add more tokens. +_VALIDATE_MARKDOWN_RENDER_FORMAT = "markdown" + + +class MthdsFile(BaseModel): + """One MTHDS file submitted to `validate_files` — content plus an optional provenance URI. + + The URI is threaded into validation diagnostics so cross-file errors name the owning + file; an absent URI yields `source: null` for that content (unless any sibling file + carries one, in which case a deterministic `inline://` label is synthesized). + """ + + #: File contents to validate. + content: str + #: Optional provenance URI threaded into validation diagnostics. + uri: str | None = None + class PipelexAPIClient(MthdsAPIClient): """Client for the Pipelex hosted API — and any MTHDS-compliant runner. @@ -256,6 +301,76 @@ async def start( self._raise_if_lifecycle_unavailable(exc.response, str(exc.request.url)) raise + @override + async def validate( # type: ignore[override] + self, + mthds_contents: list[str], + allow_signatures: bool = False, + mthds_sources: list[str] | None = None, + render: list[str] | None = None, + ) -> PipelexValidationResult: + """Parse, validate, and dry-run an MTHDS bundle — `POST /v1/validate`. + + `/validate` is 200-diagnostic: a produced verdict — valid or invalid — rides a 200 + body discriminated on `is_valid`, returned verbatim as the `PipelexValidationResult` + union (an invalid bundle is NOT raised; the caller match/cases `is_valid`). A non-2xx + means no verdict could be produced (request shape, auth, server fault) and surfaces as + `httpx.HTTPStatusError` (the inherited protocol error regime). + + This override differs from the inherited protocol `validate` in two Pipelex-API ways: + it always injects `render: ["markdown"]` (so both valid and invalid verdicts carry + `rendered_markdown`), and it accepts `mthds_sources` as a named parameter. + + Args: + mthds_contents: MTHDS contents to load (always a list, even for one file). + allow_signatures: Tolerate unimplemented pipe signatures (strict by default). + mthds_sources: Optional per-content source names, parallel to `mthds_contents`, + threaded onto each diagnostic's `source` (an unnamed content yields + `source: null`). The server 422s a length mismatch. + render: Optional Pipelex-API presentation hints; `"markdown"` is always added. + Unknown tokens are server-side lenient-ignored (never a 422). + + Returns: + The 200-diagnostic union: `PipelexValidationReport` (`is_valid: true`) or + `PipelexInvalidReport` (`is_valid: false`, with `validation_errors`), each + carrying `rendered_markdown`. + """ + extra: dict[str, Any] = {"render": _with_validate_markdown_render(render)} + if mthds_sources is not None: + extra["mthds_sources"] = mthds_sources + return await super().validate(mthds_contents, allow_signatures, extra=extra) + + async def validate_files( + self, + files: list[MthdsFile], + allow_signatures: bool = False, + render: list[str] | None = None, + ) -> PipelexValidationResult: + """Validate paired MTHDS files while preserving URI attribution for diagnostics. + + Decomposes the files into the low-level `validate(...)` payload. When any file carries + a URI, every content gets a parallel source label (a deterministic `inline://` label + for the ones without), so the server never sees a length-mismatched `mthds_sources`. + + Raises: + PipelineRequestError: If `files` is empty. + """ + if not files: + msg = "At least one MTHDS file must be provided to validate_files()." + raise PipelineRequestError(msg) + + mthds_contents = [mthds_file.content for mthds_file in files] + has_any_uri = any(mthds_file.uri is not None for mthds_file in files) + mthds_sources: list[str] | None + if has_any_uri: + mthds_sources = [ + mthds_file.uri if mthds_file.uri is not None else f"inline://file-{index + 1}.mthds" for index, mthds_file in enumerate(files) + ] + else: + mthds_sources = None + + return await self.validate(mthds_contents, allow_signatures, mthds_sources, render) + # ── Hosted extension: durable run lifecycle (NOT part of the protocol) ── # # These four methods are OWNED by this SDK: they return this package's own `runs` @@ -476,6 +591,143 @@ async def _execute_blocking( ) return _map_run_result_to_run_results(result) + # ── Pipelex product surface (hosted management routes) ───────────────── + # + # The hosted catalog/account routes the webapp drives. Every one rides the same + # `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol + # routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a + # typed `ApiResponseError` — branch on `.code`, never the HTTP status. + + async def get_me(self) -> UserProfile: + """The authenticated user's profile — `GET /v1/me`.""" + return UserProfile.model_validate(await self._request_product("GET", "me")) + + async def list_methods(self) -> list[MethodData]: + """List the caller's saved methods — `GET /v1/methods`.""" + result = await self._request_product("GET", "methods") + return [MethodData.model_validate(item) for item in result] + + async def get_method(self, method_id: str) -> MethodData: + """Fetch one method by id — `GET /v1/methods/{id}`.""" + return MethodData.model_validate(await self._request_product("GET", f"methods/{quote(method_id, safe='')}")) + + async def create_method(self, write_input: MethodWriteInput) -> MethodData: + """Create a method — `POST /v1/methods`.""" + body = write_input.model_dump(mode="json", exclude_none=True) + return MethodData.model_validate(await self._request_product("POST", "methods", body=body)) + + async def update_method(self, method_id: str, write_input: MethodWriteInput) -> MethodData: + """Replace a method (a rename is a changed `name`) — `PUT /v1/methods/{id}`.""" + body = write_input.model_dump(mode="json", exclude_none=True) + return MethodData.model_validate(await self._request_product("PUT", f"methods/{quote(method_id, safe='')}", body=body)) + + async def delete_method(self, method_id: str) -> None: + """Delete a method — `DELETE /v1/methods/{id}` (empty body).""" + await self._request_product("DELETE", f"methods/{quote(method_id, safe='')}") + + async def list_memberships(self) -> MembershipsResponse: + """The caller's org memberships + active-org feature flags — `GET /v1/organizations/memberships`.""" + return MembershipsResponse.model_validate(await self._request_product("GET", "organizations/memberships")) + + async def create_organization(self, name: str) -> Membership: + """Create an organization — `POST /v1/organizations`.""" + return Membership.model_validate(await self._request_product("POST", "organizations", body={"name": name})) + + async def rename_organization(self, org_id: str, name: str) -> Membership: + """Rename an organization — `PATCH /v1/organizations/{org_id}`.""" + return Membership.model_validate(await self._request_product("PATCH", f"organizations/{quote(org_id, safe='')}", body={"name": name})) + + async def get_subscription(self) -> SubscriptionResponse: + """The active org's subscription state — `GET /v1/billing/subscription`.""" + return SubscriptionResponse.model_validate(await self._request_product("GET", "billing/subscription")) + + async def list_plans(self) -> list[PlanView]: + """Available plans (with `is_current`) — `GET /v1/billing/plans`.""" + result = await self._request_product("GET", "billing/plans") + return [PlanView.model_validate(item) for item in result] + + async def list_invoices(self) -> list[InvoiceView]: + """Past invoices — `GET /v1/billing/invoices`.""" + result = await self._request_product("GET", "billing/invoices") + return [InvoiceView.model_validate(item) for item in result] + + async def create_checkout(self, plan: str) -> CheckoutResponse: + """Open a Stripe checkout for a plan — `POST /v1/billing/checkout`.""" + return CheckoutResponse.model_validate(await self._request_product("POST", "billing/checkout", body={"plan": plan})) + + async def change_plan(self, plan: str) -> ChangePlanResponse: + """Switch the existing subscription's plan — `POST /v1/billing/change-plan`. + + A 409 `conflict` (`ApiResponseError.code`) means there is no subscription to change — + start one via `create_checkout` first. + """ + return ChangePlanResponse.model_validate(await self._request_product("POST", "billing/change-plan", body={"plan": plan})) + + async def get_billing_portal(self) -> BillingPortalResponse: + """A Stripe billing-portal session URL — `GET /v1/billing/portal`. + + A 409 `conflict` (`ApiResponseError.code`) means there is no subscription yet. + """ + return BillingPortalResponse.model_validate(await self._request_product("GET", "billing/portal")) + + async def list_pipelex_api_keys(self) -> PipelexApiKeyList: + """List the caller's Pipelex API keys — `GET /v1/pipelex-api-keys`.""" + return PipelexApiKeyList.model_validate(await self._request_product("GET", "pipelex-api-keys")) + + async def create_pipelex_api_key(self, label: str) -> PipelexApiKeyCreated: + """Mint a Pipelex API key — `POST /v1/pipelex-api-keys`. + + The plaintext `api_key` is returned ONCE. A 409 `pipelex_api_key_limit_reached` + (`ApiResponseError.code`) means the per-account key limit is hit. + """ + return PipelexApiKeyCreated.model_validate(await self._request_product("POST", "pipelex-api-keys", body={"label": label})) + + async def revoke_pipelex_api_key(self, key_id: str) -> None: + """Revoke a Pipelex API key — `DELETE /v1/pipelex-api-keys/{id}` (empty body).""" + await self._request_product("DELETE", f"pipelex-api-keys/{quote(key_id, safe='')}") + + async def rotate_pipelex_api_key(self, key_id: str) -> PipelexApiKeyCreated: + """Rotate a Pipelex API key — `POST /v1/pipelex-api-keys/{id}/rotate` (no body). + + Returns the new plaintext `api_key` once; the old key stops working. + """ + return PipelexApiKeyCreated.model_validate(await self._request_product("POST", f"pipelex-api-keys/{quote(key_id, safe='')}/rotate")) + + async def create_gateway_api_key(self, promo_code: str | None) -> GatewayApiKey: + """Provision the gateway (LLM inference) API key — `POST /v1/gateway-api-key`. + + The JSON body is ALWAYS sent (even with `promo_code=None`) — the server 422s an empty body. + """ + return GatewayApiKey.model_validate(await self._request_product("POST", "gateway-api-key", body={"promo_code": promo_code})) + + async def get_gateway_api_key(self) -> GatewayApiKeyStatus: + """The gateway key status (`None` until provisioned) — `GET /v1/gateway-api-key`.""" + return GatewayApiKeyStatus.model_validate(await self._request_product("GET", "gateway-api-key")) + + async def submit_onboarding(self, submission: OnboardingSubmission) -> None: + """Submit the onboarding questionnaire — `POST /v1/onboarding/submit` (empty body).""" + body = submission.model_dump(mode="json", exclude_none=True) + await self._request_product("POST", "onboarding/submit", body=body) + + async def resolve_storage_url(self, uri: str) -> ResolvedStorageUrl: + """Resolve a storage URI to a presigned URL — `POST /v1/resolve-storage-url`.""" + return ResolvedStorageUrl.model_validate(await self._request_product("POST", "resolve-storage-url", body={"uri": uri})) + + async def upload(self, upload_input: UploadInput) -> UploadedFile: + """Upload a base64 file — `POST /v1/upload`.""" + body = upload_input.model_dump(mode="json", exclude_none=True) + return UploadedFile.model_validate(await self._request_product("POST", "upload", body=body)) + + async def list_runs(self, method_id: str) -> list[PipelineRun]: + """List a method's runs — `GET /v1/runs?method_id={methodId}`.""" + result = await self._request_product("GET", f"{_RUNS}?method_id={quote(method_id, safe='')}") + return [PipelineRun.model_validate(item) for item in result] + + async def update_run(self, run_id: str, update_input: UpdateRunInput) -> None: + """Patch a run's status (admin/manual) — `PUT /v1/runs/{id}` (empty body).""" + body = update_input.model_dump(mode="json", exclude_none=True) + await self._request_product("PUT", f"{_RUNS}/{quote(run_id, safe='')}", body=body) + # ── Module helpers ────────────────────────────────────────────────────── @@ -483,6 +735,16 @@ async def _execute_blocking( _KNOWN_RUN_STATUS_NAMES: frozenset[str] = frozenset(RunStatus.__members__) +def _with_validate_markdown_render(render: list[str] | None) -> list[str]: + """Ensure `"markdown"` rides the `/validate` render list, preserving order and de-duplicating. + + Mirrors the JS `withValidateMarkdownRender` (a `Set`): the caller's tokens come first, then + `"markdown"` if not already present, so both valid results and produced validation-error + verdicts carry `rendered_markdown`. + """ + return list(dict.fromkeys([*(render or []), _VALIDATE_MARKDOWN_RENDER_FORMAT])) + + def _timeout_message(run_id: str, timeout_seconds: float) -> str: """The shared `RunTimeoutError` message — the run survives and is resumable by id.""" return f"Run {run_id} did not reach a terminal state within {timeout_seconds}s; it is still executing server-side and can be resumed by id." diff --git a/pipelex_sdk/product_models.py b/pipelex_sdk/product_models.py new file mode 100644 index 0000000..4523f6e --- /dev/null +++ b/pipelex_sdk/product_models.py @@ -0,0 +1,360 @@ +"""Pipelex-product wire models — the snake_case JSON shapes the hosted-product routes speak. + +These mirror `pipelex-sdk-js/src/product-models.ts`. They are the management surface +the hosted product (`/v1/me`, `/v1/methods`, `/v1/organizations`, `/v1/billing/*`, +`/v1/pipelex-api-keys`, `/v1/gateway-api-key`, `/v1/onboarding/submit`, +`/v1/resolve-storage-url`, `/v1/upload`, `/v1/runs`) drives. + +The wire is snake_case. Each model holds only the fields the product actually +consumes — not a speculative mirror of every server field. Response models are +extension-open (`extra="allow"`): an unknown server field is preserved, not +rejected — the SDK never has to ship just to read a newly-added field. Input +models name exactly what the routes accept. + +These are Pipelex-branded (the hosted product surface), so they live in this SDK, +not in `mthds`. `PipelineRun.status` reuses the run-lifecycle `RunStatus`. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from pipelex_sdk._compat import StrEnum +from pipelex_sdk.runs import RunStatus + +# ── User profile (`/v1/me`) ───────────────────────────────────────────── + + +class UserProfile(BaseModel): + """The authenticated user's profile — `GET /v1/me`.""" + + model_config = ConfigDict(extra="allow") + + email: str + user_id: str + full_name: str + #: ISO timestamp the user completed onboarding; absent/None until they do. + onboarding_completed_at: str | None = None + + +# ── Methods catalog (`/v1/methods`) ────────────────────────────────────── + + +class MethodData(BaseModel): + """One saved method record.""" + + model_config = ConfigDict(extra="allow") + + method_id: str + name: str + #: The `.mthds` bundle source. + mthds: str + input_data: dict[str, Any] | None = None + #: Legacy persisted output spec; optional. + pipe_output: dict[str, Any] | None = None + created_at: str + updated_at: str + + +class MethodWriteInput(BaseModel): + """The create/update payload — a rename is a `PUT` with a changed `name`.""" + + name: str + mthds: str + input_data: dict[str, Any] | None = None + + +# ── Organizations (`/v1/organizations`) ────────────────────────────────── + + +class OrgRole(StrEnum): + """A member's role within an organization.""" + + ADMIN = "admin" + MEMBER = "member" + + +class Membership(BaseModel): + """One organization membership.""" + + model_config = ConfigDict(extra="allow") + + org_id: str + #: None for the implicit personal org (no backing WorkOS organization). + workos_organization_id: str | None + name: str + is_personal: bool + role_in_org: OrgRole + + +class MembershipsResponse(BaseModel): + """The caller's memberships + the active org's feature flags — `GET /v1/organizations/memberships`.""" + + model_config = ConfigDict(extra="allow") + + memberships: list[Membership] + active_org_feature_flags: list[str] + + +# ── Billing (`/v1/billing/*`) ──────────────────────────────────────────── + + +class SubscriptionResponse(BaseModel): + """The active org's subscription state — `GET /v1/billing/subscription`.""" + + model_config = ConfigDict(extra="allow") + + plan: str | None + status: str | None + can_use_service: bool + renews_at: str | None = None + ends_at: str | None = None + + +class PlanView(BaseModel): + """One available plan (with `is_current`) — `GET /v1/billing/plans`.""" + + model_config = ConfigDict(extra="allow") + + slug: str + name: str + price_display: str + monthly_price_cents: int + period: str + features: list[str] + highlight: bool + is_current: bool + + +class InvoiceView(BaseModel): + """One past invoice — `GET /v1/billing/invoices`.""" + + model_config = ConfigDict(extra="allow") + + id: str + created_at: str + status: str + amount_cents: int + currency: str + card_brand: str | None + card_last_four: str | None + refunded: bool + download_url: str | None + + +class CheckoutResponse(BaseModel): + """A Stripe checkout session URL — `POST /v1/billing/checkout`.""" + + model_config = ConfigDict(extra="allow") + + checkout_url: str | None = None + + +class ChangePlanResponse(BaseModel): + """The outcome of switching plan — `POST /v1/billing/change-plan`.""" + + model_config = ConfigDict(extra="allow") + + plan: str | None = None + status: str | None = None + charged_immediately: bool | None = None + resumed: bool | None = None + + +class BillingPortalResponse(BaseModel): + """A Stripe billing-portal session URL — `GET /v1/billing/portal`.""" + + model_config = ConfigDict(extra="allow") + + portal_url: str | None = None + + +# ── Pipelex API keys (`/v1/pipelex-api-keys`, `plx_sk_…`) ──────────────── + + +class PipelexApiKey(BaseModel): + """One Pipelex API key (metadata only — never the plaintext).""" + + model_config = ConfigDict(extra="allow") + + id: str + label: str + prefix: str + created_at: str + last_used_at: str | None + expires_at: str | None + + +class PipelexApiKeyCreated(BaseModel): + """The create/rotate response — the plaintext `api_key` is returned ONCE.""" + + model_config = ConfigDict(extra="allow") + + api_key: str + id: str + label: str + prefix: str + created_at: str + + +class PipelexApiKeyList(BaseModel): + """The caller's Pipelex API keys — `GET /v1/pipelex-api-keys`.""" + + model_config = ConfigDict(extra="allow") + + keys: list[PipelexApiKey] + + +# ── Gateway API key (`/v1/gateway-api-key`, Portkey/LLM inference key) ──── + + +class GatewayApiKey(BaseModel): + """The provisioned gateway (LLM inference) API key — `POST /v1/gateway-api-key`.""" + + model_config = ConfigDict(extra="allow") + + gateway_api_key: str + budget_usd: float | None = None + + +class GatewayApiKeyStatus(BaseModel): + """The gateway key status — `GET /v1/gateway-api-key`.""" + + model_config = ConfigDict(extra="allow") + + #: None until a gateway key has been provisioned. + gateway_api_key: str | None + + +# ── Onboarding (`/v1/onboarding/submit`) ───────────────────────────────── + + +class OnboardingRole(StrEnum): + """The respondent's role.""" + + DEVELOPER = "developer" + FOUNDER = "founder" + DATA_SCIENTIST = "data_scientist" + RESEARCHER = "researcher" + OTHER = "other" + + +class OnboardingCurrentTool(StrEnum): + """The respondent's current tool.""" + + LANGCHAIN = "langchain" + CREWAI = "crewai" + LLAMAINDEX = "llamaindex" + CUSTOM = "custom" + NONE = "none" + OTHER = "other" + + +class OnboardingInputType(StrEnum): + """A kind of material the respondent works with.""" + + DOCUMENTS = "documents" + IMAGES = "images" + VIDEOS = "videos" + AUDIO = "audio" + STRUCTURED_DATA = "structured_data" + TEXT = "text" + + +class OnboardingHeardFrom(StrEnum): + """Where the respondent heard about Pipelex.""" + + TWITTER = "twitter" + YOUTUBE = "youtube" + HACKERNEWS = "hackernews" + DISCORD = "discord" + FRIEND = "friend" + GOOGLE = "google" + CONFERENCE = "conference" + OTHER = "other" + + +class OnboardingSubmission(BaseModel): + """The onboarding questionnaire payload — `POST /v1/onboarding/submit`.""" + + role: OnboardingRole + company: str | None = None + use_case: str + process_to_transform: str + input_types: list[OnboardingInputType] + material_domain: str + current_tool: OnboardingCurrentTool + current_tool_other: str | None = None + heard_from: OnboardingHeardFrom + + +# ── Storage (`/v1/resolve-storage-url`, `/v1/upload`) ──────────────────── + + +class ResolvedStorageUrl(BaseModel): + """A storage URI resolved to a presigned URL — `POST /v1/resolve-storage-url`.""" + + model_config = ConfigDict(extra="allow") + + url: str + expires_at: str + content_type: str | None + + +class UploadInput(BaseModel): + """Upload payload — base64 `data` (the multipart hop is browser→BFF only).""" + + filename: str + data: str + content_type: str + + +class UploadedFile(BaseModel): + """An uploaded file's storage handle — `POST /v1/upload`.""" + + model_config = ConfigDict(extra="allow") + + uri: str + filename: str + + +# ── Runs list / update (`/v1/runs`) ────────────────────────────────────── +# +# The run-lifecycle status/results/start routes already live on the client +# (`runs.py`); these are the remaining catalog-style list + admin-update routes. + + +class PipeStatus(StrEnum): + """Per-pipe progress marker surfaced in a run's `pipe_statuses` map.""" + + SCHEDULED = "scheduled" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + SKIPPED = "skipped" + + +class PipelineRun(BaseModel): + """One run record in a method's run list — `GET /v1/runs?method_id=…`.""" + + model_config = ConfigDict(extra="allow") + + pipeline_run_id: str + method_id: str + pipe_code: str + workflow_id: str | None = None + status: RunStatus + result_url: str | None = None + pipe_statuses: dict[str, PipeStatus] | None = None + created_at: str + finished_at: str | None = None + + +class UpdateRunInput(BaseModel): + """The admin/manual run-status patch — `status` is a free string here.""" + + status: str + result_url: str | None = None + finished_at: str | None = None diff --git a/tests/unit/test_client_product.py b/tests/unit/test_client_product.py new file mode 100644 index 0000000..57a2356 --- /dev/null +++ b/tests/unit/test_client_product.py @@ -0,0 +1,396 @@ +"""Tests for the Pipelex product surface — verb + path + body per route, model round-trips, `.code` branching. + +Ports `pipelex-sdk-js/tests/product.test.ts`. `_send` is mocked; bodies use complete valid +shapes (Pydantic validates on the way back, unlike the TS interfaces). +""" + +import asyncio +import json + +import httpx +import pytest +from pytest_mock import MockerFixture, MockType + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError +from pipelex_sdk.product_models import ( + MethodWriteInput, + OnboardingCurrentTool, + OnboardingHeardFrom, + OnboardingInputType, + OnboardingRole, + OnboardingSubmission, + OrgRole, + UpdateRunInput, + UploadInput, +) +from pipelex_sdk.runs import RunStatus + +_BASE_URL = "http://localhost:8081" + + +def _response(status_code: int, *, json_body: object | None = None, content: bytes | None = None) -> httpx.Response: + request = httpx.Request("GET", f"{_BASE_URL}/x") + if json_body is not None: + return httpx.Response(status_code, json=json_body, request=request) + if content is not None: + return httpx.Response(status_code, content=content, request=request) + return httpx.Response(status_code, request=request) + + +class _Sent: + """The single `_send` call recorded by the spy, decoded for assertions.""" + + def __init__(self, method: str, url: str, body: object | None) -> None: + self.method = method + self.url = url + self.body = body + + +class TestClientProduct: + @pytest.fixture(autouse=True) + def _isolate(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + + def _mock_send(self, mocker: MockerFixture, client: PipelexAPIClient, response: httpx.Response) -> MockType: + return mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=response)) + + @staticmethod + def _sent(send: MockType) -> _Sent: + call = send.call_args + content = call.kwargs["content"] + body = json.loads(content) if content is not None else None + return _Sent(method=call.args[0], url=call.args[1], body=body) + + # ── User profile ───────────────────────────────────────────────── + + def test_get_me(self, mocker: MockerFixture) -> None: + client = self._client() + profile = {"email": "a@b.com", "user_id": "u1", "full_name": "A B", "onboarding_completed_at": None} + send = self._mock_send(mocker, client, _response(200, json_body=profile)) + + result = asyncio.run(client.get_me()) + + sent = self._sent(send) + assert sent.method == "GET" + assert sent.url == f"{_BASE_URL}/v1/me" + assert result.email == "a@b.com" + assert result.user_id == "u1" + assert result.onboarding_completed_at is None + + # ── Methods catalog ────────────────────────────────────────────── + + def test_list_methods(self, mocker: MockerFixture) -> None: + client = self._client() + methods = [{"method_id": "m1", "name": "M", "mthds": "...", "created_at": "t", "updated_at": "t"}] + send = self._mock_send(mocker, client, _response(200, json_body=methods)) + + result = asyncio.run(client.list_methods()) + + assert self._sent(send).url == f"{_BASE_URL}/v1/methods" + assert result[0].method_id == "m1" + assert result[0].name == "M" + + def test_get_method_encodes_id(self, mocker: MockerFixture) -> None: + client = self._client() + body = {"method_id": "a/b", "name": "M", "mthds": "...", "created_at": "t", "updated_at": "t"} + send = self._mock_send(mocker, client, _response(200, json_body=body)) + + asyncio.run(client.get_method("a/b")) + + assert self._sent(send).url == f"{_BASE_URL}/v1/methods/a%2Fb" + + def test_create_method_posts_write_body(self, mocker: MockerFixture) -> None: + client = self._client() + body = {"method_id": "m1", "name": "M", "mthds": "src", "created_at": "t", "updated_at": "t"} + send = self._mock_send(mocker, client, _response(200, json_body=body)) + + asyncio.run(client.create_method(MethodWriteInput(name="M", mthds="src", input_data={"a": 1}))) + + sent = self._sent(send) + assert sent.method == "POST" + assert sent.url == f"{_BASE_URL}/v1/methods" + assert sent.body == {"name": "M", "mthds": "src", "input_data": {"a": 1}} + + def test_update_method_puts_and_drops_absent_input_data(self, mocker: MockerFixture) -> None: + client = self._client() + body = {"method_id": "m1", "name": "Renamed", "mthds": "src", "created_at": "t", "updated_at": "t"} + send = self._mock_send(mocker, client, _response(200, json_body=body)) + + asyncio.run(client.update_method("m1", MethodWriteInput(name="Renamed", mthds="src"))) + + sent = self._sent(send) + assert sent.method == "PUT" + assert sent.url == f"{_BASE_URL}/v1/methods/m1" + # input_data is None → dropped from the wire (matches the JS undefined-drop). + assert sent.body == {"name": "Renamed", "mthds": "src"} + + def test_delete_method_tolerates_empty_204(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(204)) + + result = asyncio.run(client.delete_method("m1")) + + sent = self._sent(send) + assert result is None + assert sent.method == "DELETE" + assert sent.url == f"{_BASE_URL}/v1/methods/m1" + + # ── Organizations ──────────────────────────────────────────────── + + def test_list_memberships(self, mocker: MockerFixture) -> None: + client = self._client() + membership = {"org_id": "o1", "workos_organization_id": None, "name": "Acme", "is_personal": True, "role_in_org": "admin"} + body = {"memberships": [membership], "active_org_feature_flags": ["flag"]} + send = self._mock_send(mocker, client, _response(200, json_body=body)) + + result = asyncio.run(client.list_memberships()) + + assert self._sent(send).url == f"{_BASE_URL}/v1/organizations/memberships" + assert result.active_org_feature_flags == ["flag"] + assert result.memberships[0].role_in_org is OrgRole.ADMIN + + def test_create_organization(self, mocker: MockerFixture) -> None: + client = self._client() + body = {"org_id": "o1", "workos_organization_id": None, "name": "Acme", "is_personal": False, "role_in_org": "admin"} + send = self._mock_send(mocker, client, _response(200, json_body=body)) + + asyncio.run(client.create_organization("Acme")) + + sent = self._sent(send) + assert sent.method == "POST" + assert sent.url == f"{_BASE_URL}/v1/organizations" + assert sent.body == {"name": "Acme"} + + def test_rename_organization(self, mocker: MockerFixture) -> None: + client = self._client() + body = {"org_id": "o1", "workos_organization_id": None, "name": "Beta", "is_personal": False, "role_in_org": "member"} + send = self._mock_send(mocker, client, _response(200, json_body=body)) + + asyncio.run(client.rename_organization("o1", "Beta")) + + sent = self._sent(send) + assert sent.method == "PATCH" + assert sent.url == f"{_BASE_URL}/v1/organizations/o1" + assert sent.body == {"name": "Beta"} + + # ── Billing ─────────────────────────────────────────────────────── + + def test_get_subscription(self, mocker: MockerFixture) -> None: + client = self._client() + sub = {"plan": "pro", "status": "active", "can_use_service": True} + send = self._mock_send(mocker, client, _response(200, json_body=sub)) + + result = asyncio.run(client.get_subscription()) + + assert self._sent(send).url == f"{_BASE_URL}/v1/billing/subscription" + assert result.plan == "pro" + assert result.can_use_service is True + + def test_list_plans_and_invoices_paths(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body=[])) + + asyncio.run(client.list_plans()) + assert self._sent(send).url == f"{_BASE_URL}/v1/billing/plans" + + send.reset_mock() + asyncio.run(client.list_invoices()) + assert self._sent(send).url == f"{_BASE_URL}/v1/billing/invoices" + + def test_create_checkout(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body={"checkout_url": "https://stripe"})) + + result = asyncio.run(client.create_checkout("pro")) + + sent = self._sent(send) + assert sent.method == "POST" + assert sent.url == f"{_BASE_URL}/v1/billing/checkout" + assert sent.body == {"plan": "pro"} + assert result.checkout_url == "https://stripe" + + def test_change_plan_409_conflict_surfaces_code(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, _response(409, json_body={"code": "conflict", "message": "No subscription to change."})) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.change_plan("pro")) + + err = exc_info.value + assert err.status == 409 + assert err.code == "conflict" + assert err.server_message == "No subscription to change." + + def test_billing_portal_409_conflict_surfaces_code(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, _response(409, json_body={"code": "conflict"})) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.get_billing_portal()) + + assert exc_info.value.code == "conflict" + + # ── Pipelex API keys ───────────────────────────────────────────── + + def test_list_pipelex_api_keys(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body={"keys": []})) + + result = asyncio.run(client.list_pipelex_api_keys()) + + assert self._sent(send).url == f"{_BASE_URL}/v1/pipelex-api-keys" + assert result.keys == [] + + def test_create_pipelex_api_key_returns_once_only_plaintext(self, mocker: MockerFixture) -> None: + client = self._client() + created = {"api_key": "plx_sk_secret", "id": "k1", "label": "L", "prefix": "plx_sk", "created_at": "t"} + send = self._mock_send(mocker, client, _response(201, json_body=created)) + + result = asyncio.run(client.create_pipelex_api_key("L")) + + sent = self._sent(send) + assert sent.method == "POST" + assert sent.body == {"label": "L"} + assert result.api_key == "plx_sk_secret" + + def test_create_pipelex_api_key_409_limit_surfaces_code(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, _response(409, json_body={"code": "pipelex_api_key_limit_reached", "message": "Limit reached."})) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.create_pipelex_api_key("L")) + + assert exc_info.value.code == "pipelex_api_key_limit_reached" + + def test_revoke_pipelex_api_key_encodes_id(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(204)) + + asyncio.run(client.revoke_pipelex_api_key("a/b")) + + sent = self._sent(send) + assert sent.method == "DELETE" + assert sent.url == f"{_BASE_URL}/v1/pipelex-api-keys/a%2Fb" + + def test_rotate_pipelex_api_key_sends_no_body(self, mocker: MockerFixture) -> None: + client = self._client() + created = {"api_key": "plx_sk_new", "id": "k1", "label": "L", "prefix": "plx_sk", "created_at": "t"} + send = self._mock_send(mocker, client, _response(200, json_body=created)) + + asyncio.run(client.rotate_pipelex_api_key("k1")) + + sent = self._sent(send) + assert sent.url == f"{_BASE_URL}/v1/pipelex-api-keys/k1/rotate" + assert sent.method == "POST" + assert sent.body is None + + # ── Gateway API key ────────────────────────────────────────────── + + def test_create_gateway_api_key_always_sends_body_even_when_promo_none(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body={"gateway_api_key": "gw"})) + + asyncio.run(client.create_gateway_api_key(None)) + + sent = self._sent(send) + assert sent.method == "POST" + assert sent.url == f"{_BASE_URL}/v1/gateway-api-key" + assert sent.body == {"promo_code": None} + + def test_get_gateway_api_key_null_until_provisioned(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body={"gateway_api_key": None})) + + result = asyncio.run(client.get_gateway_api_key()) + + assert self._sent(send).url == f"{_BASE_URL}/v1/gateway-api-key" + assert result.gateway_api_key is None + + # ── Onboarding ─────────────────────────────────────────────────── + + def test_submit_onboarding_drops_absent_optionals(self, mocker: MockerFixture) -> None: + client = self._client() + submission = OnboardingSubmission( + role=OnboardingRole.DEVELOPER, + use_case="automate document review for the team", + process_to_transform="manual review", + input_types=[OnboardingInputType.DOCUMENTS], + material_domain="legal", + current_tool=OnboardingCurrentTool.NONE, + heard_from=OnboardingHeardFrom.TWITTER, + ) + send = self._mock_send(mocker, client, _response(204)) + + result = asyncio.run(client.submit_onboarding(submission)) + + sent = self._sent(send) + assert result is None + assert sent.method == "POST" + assert sent.url == f"{_BASE_URL}/v1/onboarding/submit" + assert sent.body == { + "role": "developer", + "use_case": "automate document review for the team", + "process_to_transform": "manual review", + "input_types": ["documents"], + "material_domain": "legal", + "current_tool": "none", + "heard_from": "twitter", + } + + # ── Storage ────────────────────────────────────────────────────── + + def test_resolve_storage_url(self, mocker: MockerFixture) -> None: + client = self._client() + resolved = {"url": "https://s3", "expires_at": "t", "content_type": "application/pdf"} + send = self._mock_send(mocker, client, _response(200, json_body=resolved)) + + result = asyncio.run(client.resolve_storage_url("s3://bucket/key")) + + sent = self._sent(send) + assert sent.url == f"{_BASE_URL}/v1/resolve-storage-url" + assert sent.body == {"uri": "s3://bucket/key"} + assert result.url == "https://s3" + assert result.content_type == "application/pdf" + + def test_upload_base64_payload(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body={"uri": "s3://x", "filename": "f.pdf"})) + + result = asyncio.run(client.upload(UploadInput(filename="f.pdf", data="Zm9v", content_type="application/pdf"))) + + sent = self._sent(send) + assert sent.url == f"{_BASE_URL}/v1/upload" + assert sent.body == {"filename": "f.pdf", "data": "Zm9v", "content_type": "application/pdf"} + assert result.uri == "s3://x" + + # ── Runs list / update ─────────────────────────────────────────── + + def test_list_runs_encodes_query_value(self, mocker: MockerFixture) -> None: + client = self._client() + runs = [{"pipeline_run_id": "r1", "method_id": "m/1", "pipe_code": "p", "status": "RUNNING", "created_at": "t"}] + send = self._mock_send(mocker, client, _response(200, json_body=runs)) + + result = asyncio.run(client.list_runs("m/1")) + + assert self._sent(send).url == f"{_BASE_URL}/v1/runs?method_id=m%2F1" + assert result[0].pipeline_run_id == "r1" + assert result[0].status is RunStatus.RUNNING + + def test_update_run_drops_absent_finished_at_and_tolerates_empty_body(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(204)) + + result = asyncio.run(client.update_run("r1", UpdateRunInput(status="COMPLETED", result_url="https://x"))) + + sent = self._sent(send) + assert result is None + assert sent.method == "PUT" + assert sent.url == f"{_BASE_URL}/v1/runs/r1" + assert sent.body == {"status": "COMPLETED", "result_url": "https://x"} diff --git a/tests/unit/test_client_validate.py b/tests/unit/test_client_validate.py new file mode 100644 index 0000000..fb4a216 --- /dev/null +++ b/tests/unit/test_client_validate.py @@ -0,0 +1,128 @@ +"""Tests for the `validate` override + `validate_files` — render injection, `mthds_sources`, the union round-trip.""" + +import asyncio +import json +from typing import cast + +import httpx +import pytest +from mthds.protocol.exceptions import PipelineRequestError +from mthds.runners.api.models import PipelexInvalidReport, PipelexValidationReport +from pytest_mock import MockerFixture, MockType + +from pipelex_sdk.client import MthdsFile, PipelexAPIClient + +_BASE_URL = "http://localhost:8081" + +_VALID_BODY = {"is_valid": True, "rendered_markdown": "## ok"} +_INVALID_BODY = { + "is_valid": False, + "is_runnable": False, + "message": "bundle failed", + "validation_errors": [{"category": "blueprint_validation", "message": "boom"}], + "rendered_markdown": "## errors", +} + + +class TestClientValidate: + @pytest.fixture(autouse=True) + def _isolate(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="t", api_base_url=_BASE_URL) + + def _mock_send(self, mocker: MockerFixture, client: PipelexAPIClient, *, json_body: object) -> MockType: + response = httpx.Response(200, json=json_body, request=httpx.Request("POST", f"{_BASE_URL}/v1/validate")) + return mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=response)) + + @staticmethod + def _sent_body(send: MockType) -> dict[str, object]: + content = send.call_args.kwargs["content"] + return cast("dict[str, object]", json.loads(content)) + + def test_injects_markdown_render_by_default(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, json_body=_VALID_BODY) + + asyncio.run(client.validate(["bundle"])) + + body = self._sent_body(send) + assert send.call_args.args[1] == f"{_BASE_URL}/v1/validate" + assert body["mthds_contents"] == ["bundle"] + assert body["allow_signatures"] is False + assert body["render"] == ["markdown"] + assert "mthds_sources" not in body + + def test_merges_and_dedupes_caller_render(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, json_body=_VALID_BODY) + + asyncio.run(client.validate(["bundle"], render=["html", "markdown"])) + + # Caller tokens first, markdown not duplicated (mirrors the JS Set semantics). + assert self._sent_body(send)["render"] == ["html", "markdown"] + + def test_sends_mthds_sources_when_provided(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, json_body=_VALID_BODY) + + asyncio.run(client.validate(["a", "b"], allow_signatures=True, mthds_sources=["x.mthds", "y.mthds"])) + + body = self._sent_body(send) + assert body["allow_signatures"] is True + assert body["mthds_sources"] == ["x.mthds", "y.mthds"] + assert body["render"] == ["markdown"] + + def test_returns_valid_report_with_rendered_markdown(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, json_body=_VALID_BODY) + + result = asyncio.run(client.validate(["bundle"])) + + assert isinstance(result, PipelexValidationReport) + assert result.is_valid is True + assert result.rendered_markdown == "## ok" + + def test_returns_invalid_report_union_arm(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, json_body=_INVALID_BODY) + + result = asyncio.run(client.validate(["bundle"])) + + assert isinstance(result, PipelexInvalidReport) + assert result.is_valid is False + assert result.rendered_markdown == "## errors" + assert result.validation_errors[0].message == "boom" + + # ── validate_files ─────────────────────────────────────────────── + + def test_validate_files_no_uri_omits_mthds_sources(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, json_body=_VALID_BODY) + + asyncio.run(client.validate_files([MthdsFile(content="a"), MthdsFile(content="b")])) + + body = self._sent_body(send) + assert body["mthds_contents"] == ["a", "b"] + assert "mthds_sources" not in body + assert body["render"] == ["markdown"] + + def test_validate_files_synthesizes_inline_labels_when_any_uri(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, json_body=_VALID_BODY) + + asyncio.run(client.validate_files([MthdsFile(content="a", uri="file://a.mthds"), MthdsFile(content="b")])) + + body = self._sent_body(send) + assert body["mthds_contents"] == ["a", "b"] + # Named file keeps its URI; the unnamed sibling gets a deterministic inline label. + assert body["mthds_sources"] == ["file://a.mthds", "inline://file-2.mthds"] + + def test_validate_files_empty_raises(self) -> None: + client = self._client() + with pytest.raises(PipelineRequestError): + asyncio.run(client.validate_files([])) From 1de52a74db49944206e376a629176a2e0349b89c Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:58:14 +0200 Subject: [PATCH 06/15] feat(phase-4): origin-level health probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `PipelexAPIClient.health()` — `GET {origin}/health`, served at the origin (NOT under the `/v1` prefix) and out-of-protocol. Rides the plainer `_request_json` regime (`PipelineRequestError` on a non-2xx, `ApiUnreachableError` on transport failure), not the product `ApiResponseError`. Mirrors `pipelex-sdk-js` `client.ts` `health()`. Tests assert the origin-level path (outside `/v1`), the return shape, the plainer error regime, and transport-failure mapping. Docs + CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BkphvaUvNJi6G613jq1q7j --- CHANGELOG.md | 1 + docs/architecture.md | 4 +++ pipelex_sdk/client.py | 12 +++++++ tests/unit/test_client_health.py | 59 ++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 tests/unit/test_client_health.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30db10c..43188de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,3 +15,4 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke - Lifecycle errors `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`; `RunStillRunningError` (the protocol `execute()` 202-degrade error) re-exported from `mthds` so all run/lifecycle errors share one import home. - Pipelex product surface (`pipelex_sdk/product_models.py` + client methods): the hosted management routes — user profile (`get_me`), methods catalog CRUD (`list_methods` / `get_method` / `create_method` / `update_method` / `delete_method`), organizations (`list_memberships` / `create_organization` / `rename_organization`), billing (`get_subscription` / `list_plans` / `list_invoices` / `create_checkout` / `change_plan` / `get_billing_portal`), Pipelex API keys (`list_pipelex_api_keys` / `create_pipelex_api_key` / `revoke_pipelex_api_key` / `rotate_pipelex_api_key`), the gateway inference key (`create_gateway_api_key` / `get_gateway_api_key`), onboarding (`submit_onboarding`), storage (`resolve_storage_url` / `upload`), and run records (`list_runs` / `update_run`). Documented `409`-conflict behaviors surface through `ApiResponseError.code` (`change_plan` / `get_billing_portal` ⇒ `conflict`; `create_pipelex_api_key` ⇒ `pipelex_api_key_limit_reached`). - `validate` override + `validate_files`: the Pipelex-API `/v1/validate` surface — always injects `render: ["markdown"]` (so valid and invalid verdicts both carry `rendered_markdown`), accepts a parallel `mthds_sources` array, and `validate_files` synthesizes deterministic `inline://` source labels when any file carries a URI. +- `health()`: the origin-level liveness probe — `GET {origin}/health`, served at the origin (NOT under the `/v1` prefix) and out-of-protocol. Rides the plainer `_request_json` regime (`PipelineRequestError` on a non-2xx, `ApiUnreachableError` on transport failure), not the product `ApiResponseError`. diff --git a/docs/architecture.md b/docs/architecture.md index ec4f513..6b5242f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,6 +126,10 @@ The wire models are snake_case Pydantic v2. Response models are extension-open ( - **Storage** — `resolve_storage_url(uri)` → presigned URL; `upload(UploadInput)` → the stored file handle. - **Run records** — `list_runs(method_id)` → `list[PipelineRun]` (the catalog-style list, distinct from the lifecycle status/result routes); `update_run(run_id, UpdateRunInput)` (admin/manual status patch, empty 2xx body). +## Health probe + +`health()` → `GET {origin}/health`. The one route served at the **origin**, NOT under the `/v1` prefix — the origin is derived from the base URL (`_origin_of`, exposed as `self.origin_url`), so a base URL of `https://api.pipelex.com/v1/...` still probes `https://api.pipelex.com/health`. It is **out-of-protocol**: the MTHDS Protocol defines no health route, and `/health` is neither a protocol nor a product surface. It rides `_request_json` (the plainer regime), so a non-2xx raises `PipelineRequestError` rather than the product `ApiResponseError` — liveness needs no `code` taxonomy. Transport failures still map to `ApiUnreachableError`. (Decision #5 revisits whether to bring `health` under `ApiResponseError` at the Checkpoint 5 parity gate.) + ## Out of scope for v0.1 - `/v1/build/*` helpers (the TS clients carry them; recorded as a conscious deferral). diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index a6dd0fe..074e12a 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -728,6 +728,18 @@ async def update_run(self, run_id: str, update_input: UpdateRunInput) -> None: body = update_input.model_dump(mode="json", exclude_none=True) await self._request_product("PUT", f"{_RUNS}/{quote(run_id, safe='')}", body=body) + # ── Health ───────────────────────────────────────────────────────────── + # + # The origin-level liveness probe. `/health` is served at the origin, NOT under the + # `/v1` prefix, and is out-of-protocol — the MTHDS Protocol defines no health route. + # It rides `_request_json`, the plainer regime: a non-2xx raises `PipelineRequestError`, + # not the product `ApiResponseError`, since liveness needs no `code` taxonomy. + + async def health(self) -> dict[str, Any]: + """Origin-level liveness probe — `GET {origin}/health` (NOT under the `/v1` prefix).""" + result = await self._request_json("GET", f"{self.origin_url}/health") + return cast("dict[str, Any]", result) + # ── Module helpers ────────────────────────────────────────────────────── diff --git a/tests/unit/test_client_health.py b/tests/unit/test_client_health.py new file mode 100644 index 0000000..b9213da --- /dev/null +++ b/tests/unit/test_client_health.py @@ -0,0 +1,59 @@ +"""Tests for the origin-level `health()` probe — httpx mocked.""" + +import asyncio + +import httpx +import pytest +from mthds.protocol.exceptions import PipelineRequestError +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError + +_BASE_URL = "http://localhost:8081" + + +def _response(status_code: int, *, json: object | None = None, content: bytes | None = None) -> httpx.Response: + """Build a constructed httpx.Response with a request attached.""" + request = httpx.Request("GET", f"{_BASE_URL}/health") + if json is not None: + return httpx.Response(status_code, json=json, request=request) + if content is not None: + return httpx.Response(status_code, content=content, request=request) + return httpx.Response(status_code, request=request) + + +class TestClientHealth: + @pytest.fixture(autouse=True) + def _isolate(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="t", api_base_url=_BASE_URL) + + def test_health_hits_origin_level_path_outside_v1(self, mocker: MockerFixture) -> None: + client = self._client() + send = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json={"status": "ok"}))) + result = asyncio.run(client.health()) + assert result == {"status": "ok"} + assert send.call_args.args[0] == "GET" + # `/health` lives at the origin, NOT under `/v1`. + assert send.call_args.args[1] == f"{_BASE_URL}/health" + assert "/v1/" not in send.call_args.args[1] + + def test_health_non_2xx_raises_plain_pipeline_request_error(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(503, content=b"unavailable"))) + with pytest.raises(PipelineRequestError) as exc_info: + asyncio.run(client.health()) + # The plainer regime — not the product `ApiResponseError` with its `code` taxonomy. + assert not isinstance(exc_info.value, ApiResponseError) + + def test_health_transport_failure_maps_to_unreachable(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=httpx.ConnectError("refused"))) + with pytest.raises(ApiUnreachableError): + asyncio.run(client.health()) From 66d78d846def518d0ea95e52db61f126e372dc7e Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:22:26 +0200 Subject: [PATCH 07/15] =?UTF-8?q?feat(phase-5):=20parity=20gate=20?= =?UTF-8?q?=E2=80=94=20execute=20gateway-timeout=20translation,=20version?= =?UTF-8?q?=20guard,=20release=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the pipelex-sdk-python build: the HANDOFF parity gate against the TypeScript @pipelex/sdk reference. Surface-complete with no silent gaps. Resolved the three deferred parity flags: - execute() override + PipelineExecuteTimeoutError (Phase-1 flag) — IMPLEMENTED. A blocking POST /v1/execute killed by the hosted gateway's ~30s synchronous ceiling (503/504, or a client-side request timeout, observed at/after ~28s) is translated into a clear PipelineExecuteTimeoutError pointing at start+poll, closing the one genuine JS feature gap. Other non-2xx keep the inherited httpx.HTTPStatusError regime; 202 async-degrade still raises RunStillRunningError. - health error regime (#5) — KEPT the plainer PipelineRequestError regime (already matches JS; liveness needs no code taxonomy). - validate error regime (Phase-3 flag) — DEFERRED; keeps the inherited httpx.HTTPStatusError regime, consistent with the other Python protocol routes. Also: - __version__ (pipelex_sdk/version.py) derived from installed distribution metadata via importlib.metadata, with a test asserting it matches pyproject. - Parity audit recorded in docs/architecture.md ("Parity with @pipelex/sdk"): method/model/error coverage, deliberate idiomatic ports, conscious exclusions (/v1/build/* per #8, WorkOS org-switch), and ClientAuthenticationError as a dormant non-port. - README rewritten: quickstart (validate → start_and_wait → main_stuff/native), an err.code product example, and the full no-barrel import paths. - CHANGELOG 0.1.0 entry. Tests for the gateway-timeout translation (503/504/client-timeout past ceiling → translated; fast 503 → untouched; success/202 passthrough) and the version guard. Gate green: ruff, pyright 0, mypy clean, pylint 10.00/10, all tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BkphvaUvNJi6G613jq1q7j --- CHANGELOG.md | 6 ++ README.md | 72 +++++++++++++++++---- docs/architecture.md | 44 +++++++++++-- pipelex_sdk/client.py | 102 ++++++++++++++++++++++++++---- pipelex_sdk/errors.py | 17 +++++ pipelex_sdk/version.py | 23 +++++++ tests/unit/test_client_execute.py | 96 ++++++++++++++++++++++++++++ tests/unit/test_version.py | 35 ++++++++++ 8 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 pipelex_sdk/version.py create mode 100644 tests/unit/test_client_execute.py create mode 100644 tests/unit/test_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 43188de..a70ba7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke ## [Unreleased] +## [0.1.0] - 2026-06-30 + +The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipelex/sdk`, built by inheritance on the `mthds` protocol base. Surface-complete against the TypeScript SDK (see `docs/architecture.md` → "Parity with `@pipelex/sdk`"); the `/v1/build/*` helpers and the WorkOS org-switch are consciously out of scope for this release. + ### Added - Initial repository scaffold: packaging (`pyproject.toml`), tooling (`Makefile`, ruff/pyright/mypy/pylint config mirroring `mthds-python`), and the empty `pipelex_sdk` package. @@ -16,3 +20,5 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke - Pipelex product surface (`pipelex_sdk/product_models.py` + client methods): the hosted management routes — user profile (`get_me`), methods catalog CRUD (`list_methods` / `get_method` / `create_method` / `update_method` / `delete_method`), organizations (`list_memberships` / `create_organization` / `rename_organization`), billing (`get_subscription` / `list_plans` / `list_invoices` / `create_checkout` / `change_plan` / `get_billing_portal`), Pipelex API keys (`list_pipelex_api_keys` / `create_pipelex_api_key` / `revoke_pipelex_api_key` / `rotate_pipelex_api_key`), the gateway inference key (`create_gateway_api_key` / `get_gateway_api_key`), onboarding (`submit_onboarding`), storage (`resolve_storage_url` / `upload`), and run records (`list_runs` / `update_run`). Documented `409`-conflict behaviors surface through `ApiResponseError.code` (`change_plan` / `get_billing_portal` ⇒ `conflict`; `create_pipelex_api_key` ⇒ `pipelex_api_key_limit_reached`). - `validate` override + `validate_files`: the Pipelex-API `/v1/validate` surface — always injects `render: ["markdown"]` (so valid and invalid verdicts both carry `rendered_markdown`), accepts a parallel `mthds_sources` array, and `validate_files` synthesizes deterministic `inline://` source labels when any file carries a URI. - `health()`: the origin-level liveness probe — `GET {origin}/health`, served at the origin (NOT under the `/v1` prefix) and out-of-protocol. Rides the plainer `_request_json` regime (`PipelineRequestError` on a non-2xx, `ApiUnreachableError` on transport failure), not the product `ApiResponseError`. +- `execute` override + `PipelineExecuteTimeoutError`: a blocking `POST /v1/execute` killed by the hosted gateway's ~30s synchronous ceiling (a `503`/`504`, or a client-side request timeout, observed at/after ~28s) is translated into a clear `PipelineExecuteTimeoutError` pointing at the durable start+poll path — closing a JS-parity gap (the inherited base `execute` does not do this). Every other non-2xx keeps the inherited `httpx.HTTPStatusError` regime, and the protocol's 202 async-degrade still raises `RunStillRunningError`. +- `__version__` (`pipelex_sdk.version`), derived from the installed distribution metadata so it cannot drift from the `pyproject.toml` source of truth, with a test asserting the two match. diff --git a/README.md b/README.md index 593278c..fcae8b2 100644 --- a/README.md +++ b/README.md @@ -6,30 +6,80 @@ The Python client for the [Pipelex](https://www.pipelex.com) hosted API. One-way dependency: `pipelex-sdk → mthds`. -## Status - -Early development. The public surface is being built phase by phase; see `docs/architecture.md`. - ## Install ```bash pip install pipelex-sdk ``` -## Usage +## Configuration + +Credentials resolve, in order: explicit constructor arguments → `PIPELEX_API_KEY` / `PIPELEX_API_URL` → `MTHDS_API_KEY` / `MTHDS_API_URL` (and `~/.mthds/config`) → defaults. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. The default base URL is `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`. + +The client is **async-only** (httpx `AsyncClient`) and is an async context manager. -The client is async-only (httpx `AsyncClient` under the hood) and constructs from the environment: +## Quickstart ```python from pipelex_sdk.client import PipelexAPIClient -async with PipelexAPIClient() as client: - ... +async def main() -> None: + async with PipelexAPIClient() as client: + # 1. Validate an MTHDS bundle. The verdict is always returned (never raised): + # a 200 discriminated on `is_valid`, carrying `rendered_markdown`. + report = await client.validate([bundle_text]) + print(report.rendered_markdown) + if not report.is_valid: + return + + # 2. Run a method end-to-end. `start_and_wait` self-heals across runner kinds: + # durable start+poll on the hosted API, blocking execute on a bare runner. + result = await client.start_and_wait( + pipe_code="my_pipe", + inputs={"topic": "quantum computing"}, + ) + + # 3. Read the output. Hosted runs carry `main_stuff`; the bare-runner + # fallback carries the native `pipe_output`. + print(result.main_stuff or result.pipe_output) ``` -Credentials resolve from `PIPELEX_API_KEY` / `PIPELEX_API_URL`, falling back to `MTHDS_API_KEY` / `MTHDS_API_URL` (and `~/.mthds/config`). A token is optional — anonymous access works against the protocol routes; product routes require authentication. The default base URL is `https://api.pipelex.com`. +### Long runs: start + poll explicitly + +Behind the hosted gateway, a synchronous `execute()` is cut off at ~30s and surfaces a `PipelineExecuteTimeoutError` pointing here. For long methods, drive the durable lifecycle yourself — the run survives client disconnects and is resumable by `pipeline_run_id`: + +```python +ack = await client.start(pipe_code="long_pipe", inputs={...}) +result = await client.wait_for_result(ack.pipeline_run_id) +``` + +### Product routes: branch on `err.code`, not the HTTP status + +The hosted product routes raise a typed `ApiResponseError` carrying the RFC 9457 `code` discriminant. Branch on `err.code`, which is decoupled from the transport status: + +```python +from pipelex_sdk.errors import ApiResponseError + +try: + created = await client.create_pipelex_api_key(label="ci") + print(created.api_key) # plaintext — returned only once +except ApiResponseError as exc: + if exc.code == "pipelex_api_key_limit_reached": + print("Per-account key limit reached — revoke an old key first.") + else: + raise +``` + +## Public import paths (no barrel) + +There is no barrel import — package `__init__.py` files stay empty. Import each symbol from its module: -There is no barrel import: import from the full module path (e.g. `from pipelex_sdk.client import PipelexAPIClient`). The quickstart and the full list of public import paths will be documented here as the surface lands. +- **Client & construction** — `from pipelex_sdk.client import PipelexAPIClient, DEFAULT_API_BASE_URL, MthdsFile` +- **Run lifecycle types** — `from pipelex_sdk.runs import RunStatus, RunPublic, RunRead, RunResults, RunResultState, WaitForResultOptions, PollInfo` +- **Product wire models** — `from pipelex_sdk.product_models import UserProfile, MethodData, MethodWriteInput, Membership, MembershipsResponse, SubscriptionResponse, PlanView, InvoiceView, OnboardingSubmission, UploadInput, UploadedFile, PipelineRun, ...` +- **Typed errors** — `from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError, PipelineExecuteTimeoutError, RunFailedError, RunTimeoutError, RunLifecycleUnavailableError, RunStillRunningError` +- **Version** — `from pipelex_sdk.version import __version__` +- **Protocol surface** (the MTHDS standard's wire types) comes from the `mthds` dependency — e.g. `from mthds.protocol.exceptions import PipelineRequestError`, `from mthds.runners.api.models import PipelexValidationResult`. ## Development @@ -40,7 +90,7 @@ make agent-test # run the test suite quietly (prints only on failure) make check # full gate: agent-check aggregate + unused-imports + pylint ``` -See `CLAUDE.md` for the coding standards and `docs/architecture.md` for the design. +See `CLAUDE.md` for the coding standards and `docs/architecture.md` for the design (including the parity map against `@pipelex/sdk`). ## License diff --git a/docs/architecture.md b/docs/architecture.md index 6b5242f..a610cc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # pipelex-sdk architecture -This document grows phase by phase as the SDK is built. It is the design reference for the package. +The design reference for the package. It covers the full `0.1.0` surface and records the parity audit against the TypeScript `@pipelex/sdk` reference (see "Parity with `@pipelex/sdk`" at the end). ## What this is @@ -8,7 +8,7 @@ This document grows phase by phase as the SDK is built. It is the design referen It is the **hosted superset** of the MTHDS Protocol: -- the five normative MTHDS Protocol routes — `POST /execute`, `POST /start`, `POST /validate`, `GET /models`, `GET /version` — inherited from the protocol base; +- the five normative MTHDS Protocol routes — `POST /execute`, `POST /start`, `POST /validate`, `GET /models`, `GET /version` — built on the protocol base (`models` / `version` inherited as-is; `execute` / `start` / `validate` lightly overridden for Pipelex-API behaviors documented below); - **plus** the durable run lifecycle (`get_run_status`, `get_run_result`, `wait_for_result`, `start_and_wait`); - **plus** the Pipelex product surface (methods catalog, organizations, billing, API keys, onboarding, storage, run records). @@ -58,12 +58,18 @@ The `problem+json` / `HTTPException` error body is parsed by `_parse_error_body` ## Error regimes -Two regimes, ported faithfully from the TS SDK (decision #5 — not unified yet): +Three regimes, ported faithfully from the TS SDK (the inherited-protocol vs product split is decision #5 — deliberately not unified): - **Product routes** raise a typed `ApiResponseError` (subclass of `PipelineRequestError`) carrying the RFC 9457 `code` discriminant — consumers branch on `err.code` (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`), never on the HTTP status. It also carries `status`, `status_text`, `response_body`, `error_type`, `server_message`, and `validation_errors`. - **Transport failures** (DNS/connect/TLS/timeout) raise `ApiUnreachableError` (subclass of `PipelineRequestError`) with `api_url` and `code`. -- **`health` / `_request_json`** raise the plainer `PipelineRequestError` on a non-2xx response (decision #5 revisits whether to bring this under `ApiResponseError` at Checkpoint 5). -- **Inherited protocol routes** (`execute` / `start` / `validate` / `models` / `version`) keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. +- **`health` / `_request_json`** raise the plainer `PipelineRequestError` on a non-2xx response. **(Checkpoint-5 decision: kept, not unified.)** Liveness is a binary up/down probe that needs no `code` taxonomy, and this already matches the JS `health` regime — bringing it under `ApiResponseError` would be over-engineering and a JS divergence. (Python's `PipelineRequestError` is already a typed improvement over the JS plain `Error`.) +- **Inherited protocol routes** (`execute` / `start` / `validate` / `models` / `version`) keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. The one typed addition is `PipelineExecuteTimeoutError` (below), raised by `execute` for the hosted gateway's synchronous cut-off. + +## `execute` override (hosted gateway-timeout translation) + +The protocol `execute` is **overridden** to add one Pipelex-API behavior the bare protocol route doesn't carry, while keeping the inherited error regime for everything else. Behind the hosted gateway, a synchronous request is cut off at ~30s; a blocking `execute` that exceeds that comes back as a gateway `503`/`504` (or, less commonly, a client-side request timeout). The override times the call and, when such a failure is observed **at or after ~28s elapsed**, translates it into a clear `PipelineExecuteTimeoutError` whose message points the caller at the durable start+poll path. The ~28s threshold guards against mislabeling a *fast* `503` (the runner genuinely down) as a timeout. This closes a JS-parity gap — the inherited base `execute` does no such translation (the Phase-1 flag, resolved at Checkpoint 5 by **implementing** it rather than deferring). + +Everything else stays the inherited regime: the protocol's optional 202 async-degrade still raises `RunStillRunningError` (from the base `execute`), and every other non-2xx keeps `httpx.HTTPStatusError` — consistent with the other inherited protocol routes (decision #5). The `_execute_blocking` bare-runner fallback (below) calls this same overridden `execute`, so it inherits the translation; off-platform there is no gateway cap, so the `503/504`-after-28s condition effectively never fires there. ## Run lifecycle (hosted extension) @@ -110,6 +116,8 @@ The protocol `validate` is **overridden** (not inherited) to add the two Pipelex The override delegates the wire call to the inherited base `validate` (passing `render` / `mthds_sources` through the protocol's `extra` extension passthrough), so the body-building and transport stay shared; only the Pipelex presentation/sources concerns live here. The validation models (`PipelexValidationResult` = `PipelexValidationReport | PipelexInvalidReport`, with `rendered_markdown`) are reused from `mthds` for now (the brand-layering follow-up #9 — they would eventually migrate here to fully mirror the JS boundary). +**Checkpoint-5 decision (validate error regime):** the delegation keeps the inherited `httpx.HTTPStatusError` regime on a *no-verdict* non-2xx, where the JS `validate` raises `ApiResponseError`. Kept as-is (deferred parity), because in both SDKs `validate`'s error regime matches the *other* protocol routes of that SDK — JS routes all raise `ApiResponseError`, Python protocol routes all inherit `httpx.HTTPStatusError` (decision #5). Making Python's `validate` alone raise `ApiResponseError` would make it inconsistent with `execute`/`start`/`models`/`version`, which is worse than the JS divergence. The verdict itself (valid/invalid) is always a 200 either way — only the no-verdict failure *presentation* differs. + ## Pipelex product surface (hosted management routes) The hosted catalog/account routes the webapp drives (`pipelex_sdk/product_models.py` + the client's product methods). Every route rides the same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a typed `ApiResponseError` — **consumers branch on `.code`, never the HTTP status**. @@ -128,7 +136,7 @@ The wire models are snake_case Pydantic v2. Response models are extension-open ( ## Health probe -`health()` → `GET {origin}/health`. The one route served at the **origin**, NOT under the `/v1` prefix — the origin is derived from the base URL (`_origin_of`, exposed as `self.origin_url`), so a base URL of `https://api.pipelex.com/v1/...` still probes `https://api.pipelex.com/health`. It is **out-of-protocol**: the MTHDS Protocol defines no health route, and `/health` is neither a protocol nor a product surface. It rides `_request_json` (the plainer regime), so a non-2xx raises `PipelineRequestError` rather than the product `ApiResponseError` — liveness needs no `code` taxonomy. Transport failures still map to `ApiUnreachableError`. (Decision #5 revisits whether to bring `health` under `ApiResponseError` at the Checkpoint 5 parity gate.) +`health()` → `GET {origin}/health`. The one route served at the **origin**, NOT under the `/v1` prefix — the origin is derived from the base URL (`_origin_of`, exposed as `self.origin_url`), so a base URL of `https://api.pipelex.com/v1/...` still probes `https://api.pipelex.com/health`. It is **out-of-protocol**: the MTHDS Protocol defines no health route, and `/health` is neither a protocol nor a product surface. It rides `_request_json` (the plainer regime), so a non-2xx raises `PipelineRequestError` rather than the product `ApiResponseError` — liveness needs no `code` taxonomy. Transport failures still map to `ApiUnreachableError`. (Checkpoint-5 decision: kept the plainer regime — see "Error regimes" above.) ## Out of scope for v0.1 @@ -136,3 +144,27 @@ The wire models are snake_case Pydantic v2. Response models are extension-open ( - Organization *switch* (a WorkOS session operation, not a `/v1` route). - A `~/.pipelex/config` file reader (env-only for now, matching the JS SDK). - A synchronous client facade. + +## Versioning + +`__version__` (`pipelex_sdk.version`) is read from the installed distribution metadata via `importlib.metadata`, so there is no hardcoded constant to drift from the `pyproject.toml` source of truth — the analogue of the JS SDK's `SDK_VERSION` guard, but with nothing to keep in sync by hand. `tests/unit/test_version.py` asserts the resolved value matches the version declared in `pyproject.toml`, catching a stale install (e.g. an editable tree whose metadata was not refreshed after a bump). + +## Parity with `@pipelex/sdk` + +This SDK is a faithful port of the TypeScript `@pipelex/sdk` (`PipelexApiClient`). The Checkpoint-5 parity audit walked the JS `src/client.ts`, `src/index.ts` (the public barrel), and `docs/architecture.md`, plus a field-by-field sweep of `runs.ts` / `product-models.ts` / `models.ts` against their Python counterparts. Result: **surface-complete, with no silent gaps** — every JS method, model, and error has a Python equivalent or a consciously-recorded exclusion. + +**Methods** — full coverage: protocol (`execute`, `start`, `validate`, `validate_files`, `models`, `version`), durable lifecycle (`get_run_status`, `get_run_result`, `wait_for_result`, `start_and_wait`, the private `_supports_run_lifecycle` / `_execute_blocking`), the whole product surface (profile, methods CRUD, organizations, billing, Pipelex API keys, gateway key, onboarding, storage, run records), and `health`. + +**Models** — full field-for-field match across the run-lifecycle types and the product wire models. Deliberate idiomatic ports (not gaps): milliseconds → seconds (`interval_seconds` / `timeout_seconds` / `elapsed_seconds`); the JS `AbortSignal` → Python `asyncio` cancellation (no `signal` field); JS inline string-unions promoted to `StrEnum`s (`OrgRole`, `PipeStatus`, the onboarding fields) with identical wire values; response models are `extra="allow"` for forward-compat. The validate/`Dict*` models are reused from `mthds` (brand-layering follow-up #9), not redefined here. + +**Errors** — `ApiResponseError`, `ApiUnreachableError`, `PipelineExecuteTimeoutError`, `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError` are owned here; `RunStillRunningError` is re-exported from `mthds`. `ClientAuthenticationError` is **not** ported: it is a dormant export in the JS barrel (defined and exported but never raised by the client), and in Python it already lives in `mthds.runners.api.exceptions` — importable directly if ever needed, with no barrel here to re-export it through. + +**Resolved parity flags (Checkpoint 5):** + +- **`PipelineExecuteTimeoutError` / `execute` gateway-timeout translation** (Phase-1 flag) — **implemented** (see "`execute` override" above), closing the one genuine feature gap rather than deferring it. +- **`health` error regime** (decision #5) — **kept** the plainer `PipelineRequestError` regime; already matches JS, needs no `code` taxonomy. +- **`validate` error regime** (Phase-3 flag) — **deferred**; keeps the inherited `httpx.HTTPStatusError` regime for consistency with the other Python protocol routes (see "`validate` override" above). + +**Conscious exclusions:** the `/v1/build/*` authoring helpers (decision #8 — a future follow-up if a consumer needs them) and the organization *switch* (a WorkOS session op, not a `/v1` route). + +**Intentional divergences from the JS SDK** (Python house style / clean inheritance): no barrel (`__init__.py` stays empty; import via full paths); inheritance on `MthdsAPIClient` rather than the JS composition-of-types; async-only; `__version__` derived from installed metadata rather than a hand-synced constant. diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 074e12a..298f8f9 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -1,17 +1,18 @@ """`PipelexAPIClient` — the Python client for the Pipelex hosted API. Built by inheritance on `mthds`'s protocol base (`MthdsAPIClient`): the protocol -routes (`execute` / `start` / `validate` / `models` / `version`), the transport -(`_send`, `_url`), and the request-body builders are reused; this client adds the -Pipelex branding (env resolution, optional token, host-only base-URL validation), -the richer transport/error layer the product and lifecycle phases build on, and — -in later phases — the durable run lifecycle, the product surface, and `health`. +routes (`models` / `version` reused as-is; `execute` / `start` / `validate` overridden), +the transport (`_send`, `_url`), and the request-body builders are reused; this client +adds the Pipelex branding (env resolution, optional token, host-only base-URL +validation), the richer transport/error layer, the durable run lifecycle, the product +surface, and `health`. This module holds construction, the transport extension helpers (`_request_product`, `_request_json`, `_send_or_unreachable`), the `problem+json` error-body parser, the -durable run lifecycle, the `validate` override (markdown-render injection + -`validate_files`), and the Pipelex product surface (methods, organizations, billing, -API keys, onboarding, storage, run records). `health` lands in Phase 4. +`execute` override (hosted gateway-timeout translation), the durable run lifecycle, the +`validate` override (markdown-render injection + `validate_files`), the Pipelex product +surface (methods, organizations, billing, API keys, onboarding, storage, run records), +and the origin-level `health` probe. """ from __future__ import annotations @@ -20,7 +21,7 @@ import json import os import re -import time +from time import monotonic from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, cast from urllib.parse import quote, urlparse @@ -36,6 +37,7 @@ from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, + PipelineExecuteTimeoutError, RunFailedError, RunLifecycleUnavailableError, RunTimeoutError, @@ -100,6 +102,11 @@ _POLL_REQUEST_TIMEOUT_SECONDS = 30.0 # single status/result/product GETs; the hosted gateway caps responses at ~30s. _DEFAULT_DEGRADED_RETRY_SECONDS = 5 # matches the platform's `_DEGRADE_RETRY_AFTER_SECONDS`. +# The hosted gateway caps synchronous requests at ~30s. A blocking-`execute` failure at/after +# this elapsed threshold is the gateway cut-off, not a transient outage — the threshold guards +# against mislabeling a fast 503 (runner genuinely down) as a timeout. +_GATEWAY_TIMEOUT_THRESHOLD_SECONDS = 28.0 + _PIPELEX_API_KEY_ENV = "PIPELEX_API_KEY" _PIPELEX_API_URL_ENV = "PIPELEX_API_URL" @@ -266,6 +273,53 @@ def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) -> ) raise RunLifecycleUnavailableError(msg, api_url=self.api_base_url) + # ── Protocol surface: `execute` override (gateway-timeout translation) ── + + @override + async def execute( + self, + pipe_code: str | None = None, + mthds_contents: list[str] | None = None, + inputs: PipelineInputs | WorkingMemoryAbstract[StuffType] | None = None, + output_name: str | None = None, + output_multiplicity: VariableMultiplicity | None = None, + dynamic_output_concept_ref: str | None = None, + extra: dict[str, Any] | None = None, + ) -> DictRunResultExecute: + """Execute a method synchronously and wait for its completion — `POST /v1/execute`. + + Identical to the inherited protocol `execute`, except a failure consistent with the + hosted gateway's ~30s synchronous ceiling — a gateway `503`/`504`, or a client-side + request timeout, after at least ~28s have elapsed — is translated into a clear + `PipelineExecuteTimeoutError` pointing at the durable start+poll path, matching the JS + SDK. The protocol's optional 202 async-degrade still raises `RunStillRunningError` + (from the inherited `execute`), and every other non-2xx keeps the inherited + `httpx.HTTPStatusError` regime (consistent with the other inherited protocol routes). + + Raises: + PipelineExecuteTimeoutError: The blocking request hit the hosted gateway's ~30s + synchronous ceiling — use `start_and_wait` (or `start` + `wait_for_result`). + RunStillRunningError: The server answered 202 (the protocol's optional async + degrade) — the run continues server-side; resume by `pipeline_run_id`. + httpx.HTTPStatusError: Any other non-2xx response (the inherited regime). + """ + started_at = monotonic() + try: + return await super().execute( + pipe_code=pipe_code, + mthds_contents=mthds_contents, + inputs=inputs, + output_name=output_name, + output_multiplicity=output_multiplicity, + dynamic_output_concept_ref=dynamic_output_concept_ref, + extra=extra, + ) + except (httpx.HTTPStatusError, httpx.TimeoutException) as exc: + elapsed_seconds = monotonic() - started_at + if _is_gateway_timeout(exc, elapsed_seconds): + raise PipelineExecuteTimeoutError(_execute_timeout_message(elapsed_seconds), elapsed_seconds=elapsed_seconds) from exc + raise + # ── Protocol surface: `start` override (bare-runner 404 → typed error) ── @override @@ -452,11 +506,11 @@ async def wait_for_result(self, run_id: str, options: WaitForResultOptions | Non awaiting task raises `asyncio.CancelledError` out of this loop, leaving the run resumable. """ opts = options or WaitForResultOptions() - started_at = time.monotonic() + started_at = monotonic() attempt = 0 while True: - elapsed = time.monotonic() - started_at + elapsed = monotonic() - started_at remaining = opts.timeout_seconds - elapsed if remaining <= 0: raise RunTimeoutError(_timeout_message(run_id, opts.timeout_seconds), run_id=run_id, timeout_seconds=opts.timeout_seconds) @@ -474,7 +528,7 @@ async def wait_for_result(self, run_id: str, options: WaitForResultOptions | Non # state is RunResultRunning — decide whether to keep waiting. attempt += 1 - elapsed = time.monotonic() - started_at + elapsed = monotonic() - started_at if elapsed >= opts.timeout_seconds: raise RunTimeoutError(_timeout_message(run_id, opts.timeout_seconds), run_id=run_id, timeout_seconds=opts.timeout_seconds) if opts.on_poll is not None: @@ -762,6 +816,30 @@ def _timeout_message(run_id: str, timeout_seconds: float) -> str: return f"Run {run_id} did not reach a terminal state within {timeout_seconds}s; it is still executing server-side and can be resumed by id." +def _is_gateway_timeout(exc: httpx.HTTPStatusError | httpx.TimeoutException, elapsed_seconds: float) -> bool: + """Whether a failed blocking `execute` is the hosted gateway's ~30s synchronous cut-off. + + The elapsed threshold guards against mislabeling a fast `503` (the runner genuinely down) + as a timeout: a gateway `503`/`504`, or a client-side request timeout, only counts once the + request has run at least ~28s. Mirrors the JS `isGatewayTimeout`. + """ + if elapsed_seconds < _GATEWAY_TIMEOUT_THRESHOLD_SECONDS: + return False + if isinstance(exc, httpx.TimeoutException): + return True + return exc.response.status_code in {503, 504} + + +def _execute_timeout_message(elapsed_seconds: float) -> str: + """The `PipelineExecuteTimeoutError` message — point the caller at the durable start+poll path.""" + seconds = round(elapsed_seconds) + return ( + f"The Pipelex Hosted API times out synchronous requests after ~30s — this run took {seconds}s. " + "The blocking execute path can't run methods longer than 30s behind the gateway. " + "Start the run and poll for its result instead: `start()` then `wait_for_result(run_id)` (or `start_and_wait`)." + ) + + def _is_missing_route_404(response: httpx.Response) -> bool: """Whether a 404 is an unmatched-route 404 (no platform deployed) rather than the platform's structured run-not-found 404. The platform wraps its 404s in RFC 7807 problem+json with a stable diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index 4e80216..9ec872e 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -10,6 +10,8 @@ - `ApiResponseError` — a non-2xx response from the API, carrying the parsed problem-details and, for the product routes, the stable RFC 9457 `code` discriminant a consumer branches on (decoupled from the HTTP status). +- `PipelineExecuteTimeoutError` — a blocking `execute()` killed by the hosted gateway's + ~30s synchronous-request ceiling; points the caller at the durable start+poll path. The run-lifecycle errors (`RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`) are owned here (ported from `mthds-python` in @@ -87,6 +89,21 @@ def __init__( self.code = code +class PipelineExecuteTimeoutError(PipelineRequestError): + """Raised when a blocking `execute()` (`POST /v1/execute`) is killed by the hosted + gateway's ~30s synchronous-request ceiling. + + The blocking path cannot run methods longer than ~30s behind the hosted gateway — use + the durable run lifecycle (`start` + `wait_for_result`, or `start_and_wait`) instead, + which survives long runs and client disconnects. `elapsed_seconds` is how long the + request ran before the gateway cut it off. + """ + + def __init__(self, message: str, elapsed_seconds: float) -> None: + super().__init__(message) + self.elapsed_seconds = elapsed_seconds + + class RunFailedError(PipelineRequestError): """Raised when a run reaches a terminal state that is not `COMPLETED`. diff --git a/pipelex_sdk/version.py b/pipelex_sdk/version.py new file mode 100644 index 0000000..882b49a --- /dev/null +++ b/pipelex_sdk/version.py @@ -0,0 +1,23 @@ +"""Package version, derived from the installed distribution metadata. + +`__version__` is read from the installed `pipelex-sdk` distribution (via +`importlib.metadata`), so there is no hardcoded constant to drift from the +`pyproject.toml` source of truth — the analogue of the JS SDK's `SDK_VERSION` +sync guard, but with nothing to keep in sync by hand. `tests/unit/test_version.py` +asserts the resolved value matches the version declared in `pyproject.toml`, +catching a stale install (e.g. an editable tree whose metadata was not refreshed +after a bump). +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + +#: The PyPI distribution name (NOT the import package `pipelex_sdk`). +PACKAGE_NAME = "pipelex-sdk" + +try: + __version__: str = version(PACKAGE_NAME) +except PackageNotFoundError: + # Running from a source tree that was never installed (no dist metadata). + __version__ = "0.0.0" diff --git a/tests/unit/test_client_execute.py b/tests/unit/test_client_execute.py new file mode 100644 index 0000000..75d3e07 --- /dev/null +++ b/tests/unit/test_client_execute.py @@ -0,0 +1,96 @@ +"""Tests for the `execute` override — the hosted gateway ~30s timeout translation (httpx mocked). + +Mirrors `pipelex-sdk-js/tests/client.test.ts` "execute gateway 30s timeout": a 503/504 — or a +client-side request timeout — at/after the ~28s ceiling becomes a clear `PipelineExecuteTimeoutError` +pointing at start+poll, while a fast 503 stays the inherited `httpx.HTTPStatusError` (runner down, +not a timeout) and the 202 async-degrade stays the inherited `RunStillRunningError`. +""" + +import asyncio + +import httpx +import pytest +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import PipelineExecuteTimeoutError, RunStillRunningError + +_BASE_URL = "http://localhost:8081" + +_EXECUTE_BODY: dict[str, object] = { + "pipeline_run_id": "run-x", + "pipe_output": {"working_memory": {"root": {}, "aliases": {}}, "pipeline_run_id": "run-x"}, +} + + +def _response(status_code: int, *, json: object | None = None) -> httpx.Response: + request = httpx.Request("POST", f"{_BASE_URL}/v1/execute") + if json is None: + return httpx.Response(status_code, request=request) + return httpx.Response(status_code, json=json, request=request) + + +class TestClientExecute: + @pytest.fixture(autouse=True) + def _mock_credentials(self, mocker: MockerFixture) -> None: + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"}, + ) + + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + + def test_gateway_503_past_ceiling_translates_to_timeout(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(503))) + # start = 0s, failure observed at 31s → over the ~30s gateway ceiling. + mocker.patch("pipelex_sdk.client.monotonic", side_effect=[0.0, 31.0]) + + with pytest.raises(PipelineExecuteTimeoutError) as exc_info: + asyncio.run(client.execute(pipe_code="p")) + + error = exc_info.value + assert error.elapsed_seconds == 31.0 + assert "30s" in str(error) + assert "wait_for_result" in str(error) + + def test_gateway_504_past_ceiling_translates_to_timeout(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(504))) + mocker.patch("pipelex_sdk.client.monotonic", side_effect=[0.0, 29.0]) + + with pytest.raises(PipelineExecuteTimeoutError): + asyncio.run(client.execute(pipe_code="p")) + + def test_client_timeout_past_ceiling_translates_to_timeout(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=httpx.ReadTimeout("timed out"))) + mocker.patch("pipelex_sdk.client.monotonic", side_effect=[0.0, 30.5]) + + with pytest.raises(PipelineExecuteTimeoutError): + asyncio.run(client.execute(pipe_code="p")) + + def test_fast_503_stays_inherited_http_status_error(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(503))) + # Failure at 2s — under the ceiling: a genuinely-down runner, not a gateway timeout. + mocker.patch("pipelex_sdk.client.monotonic", side_effect=[0.0, 2.0]) + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + asyncio.run(client.execute(pipe_code="p")) + assert not isinstance(exc_info.value, PipelineExecuteTimeoutError) + + def test_success_passes_through_untranslated(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json=_EXECUTE_BODY))) + + result = asyncio.run(client.execute(pipe_code="p")) + assert result.pipeline_run_id == "run-x" + + def test_202_degrade_stays_run_still_running_error(self, mocker: MockerFixture) -> None: + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(202, json={"pipeline_run_id": "run-x"}))) + + with pytest.raises(RunStillRunningError): + asyncio.run(client.execute(pipe_code="p")) diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..474f6c7 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,35 @@ +"""Version sync guard — the runtime `__version__` must match the `pyproject.toml` source of truth.""" + +import re +from pathlib import Path + +import pytest + +from pipelex_sdk.version import __version__ + +_PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" +_SEMVER = re.compile(r"^\d+\.\d+\.\d+") + + +def _declared_version() -> str: + """The `[project].version` declared in pyproject.toml, read without a TOML dependency. + + `^version` (MULTILINE) anchors at the line start, so it never matches the + `python_version` / `target-version` / `required-version` tooling keys. + """ + text = _PYPROJECT.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + assert match is not None, "no [project].version found in pyproject.toml" + return match.group(1) + + +class TestVersion: + def test_version_is_semver(self) -> None: + assert _SEMVER.match(__version__), f"__version__ {__version__!r} is not a semver string" + + def test_version_matches_pyproject(self) -> None: + # A stale install (editable tree whose dist metadata was not refreshed after a + # bump) would misreport the SDK to consumers doing diagnostics/compat checks. + if __version__ == "0.0.0": + pytest.skip("pipelex-sdk distribution metadata not installed; run `make install`") + assert __version__ == _declared_version() From 207f76f14ad3f719d9ca0003d5bf70db0374a348 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:54:21 +0200 Subject: [PATCH 08/15] chore(phase-6): drop transient lifecycle override markers; bump mthds floor to 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mthds 0.6.0 is now protocol-only, so the owned lifecycle methods (get_run_status / get_run_result / wait_for_result / start_and_wait) and _raise_if_lifecycle_unavailable no longer shadow base methods — remove their @override decorators and # type: ignore[override] suppressions, which were transition artifacts. @override stays on start_client / execute / start / validate (permanent protocol-route overrides; validate keeps its permanent # type: ignore[override] since its signature genuinely differs). Bump the mthds floor to >=0.6.0 (the protocol-only base the override cleanup type-checks against). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BkphvaUvNJi6G613jq1q7j --- pipelex_sdk/client.py | 24 ++++++++---------------- pyproject.toml | 4 ++-- uv.lock | 2 +- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 298f8f9..5843da5 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -257,7 +257,6 @@ def _raise_api_response_error(self, *, method: str, endpoint: str, response: htt code=parsed.code, ) - @override def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) -> None: """Translate a "route absent" 404 (a bare pipelex-api with no platform block) into a clear `RunLifecycleUnavailableError`. The platform's own 404s (run not found / cross-org) carry a @@ -427,16 +426,12 @@ async def validate_files( # ── Hosted extension: durable run lifecycle (NOT part of the protocol) ── # - # These four methods are OWNED by this SDK: they return this package's own `runs` - # types (a Pipelex-branded surface), which are nominally distinct from the same-shaped - # types still in `mthds` during the transition. While the base `MthdsAPIClient` also - # declares them (until HANDOFF Phase 6 strips them), they read as incompatible overrides - # to the type-checker — hence `# type: ignore[override]`. Phase 6 deletes the base copies, - # after which the suppressions become unnecessary (harmless) and the `@override` markers - # should be removed alongside the base methods. + # These four methods are OWNED by this SDK and return this package's own `runs` + # types (a Pipelex-branded surface). The protocol base `MthdsAPIClient` is + # protocol-only — it declares no run lifecycle — so these are plain methods, not + # overrides (no `@override`, no override suppressions). - @override - async def get_run_status(self, run_id: str) -> RunRead: # type: ignore[override] + async def get_run_status(self, run_id: str) -> RunRead: """Fetch a run's status by bare id — `GET /v1/runs/{run_id}/status`. Self-healing: a finished-but-unrecorded run resolves to its true terminal status on read. @@ -458,8 +453,7 @@ async def get_run_status(self, run_id: str) -> RunRead: # type: ignore[override run = run.model_copy(update={"retry_after_seconds": retry_after}) return run - @override - async def get_run_result(self, run_id: str) -> RunResultState: # type: ignore[override] + async def get_run_result(self, run_id: str) -> RunResultState: """Single-shot result lookup — `GET /v1/runs/{run_id}/results`. Maps the platform's poll semantics to a discriminated union: @@ -496,8 +490,7 @@ async def get_run_result(self, run_id: str) -> RunResultState: # type: ignore[o result = RunResults.model_validate(response.json()) return RunResultCompleted(pipeline_run_id=run_id, result=result) - @override - async def wait_for_result(self, run_id: str, options: WaitForResultOptions | None = None) -> RunResults: # type: ignore[override] + async def wait_for_result(self, run_id: str, options: WaitForResultOptions | None = None) -> RunResults: """Poll a run to a terminal state and return its result. Resolves on `COMPLETED`, raises `RunFailedError` on any other terminal status, and raises @@ -554,8 +547,7 @@ async def _supports_run_lifecycle(self) -> bool: self._lifecycle_available = not (isinstance(implementation, str) and implementation == _BARE_RUNNER_IMPLEMENTATION) return self._lifecycle_available - @override - async def start_and_wait( # type: ignore[override] + async def start_and_wait( self, pipe_code: str | None = None, mthds_contents: list[str] | None = None, diff --git a/pyproject.toml b/pyproject.toml index 69b7d0f..c3a3978 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "mthds>=0.5.0", + "mthds>=0.6.0", "pydantic>=2.10.6,<3.0.0", "backports.strenum>=1.3.0 ; python_version < '3.11'", "typing-extensions>=4.0.0", @@ -49,7 +49,7 @@ Changelog = "https://github.com/Pipelex/pipelex-sdk-python/blob/main/CHANGELOG.m # For local development, resolve `mthds` from the sibling workspace checkout. # uv ignores [tool.uv.sources] when building/publishing the wheel, so the -# published package depends on `mthds>=0.5.0` from PyPI. +# published package depends on `mthds>=0.6.0` from PyPI (the protocol-only base). [tool.uv.sources] mthds = { path = "../mthds-python", editable = true } diff --git a/uv.lock b/uv.lock index c2d110f..2f15833 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.5.0" +version = "0.6.0" source = { editable = "../mthds-python" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, From 9d176bc775501c1653f0dd098ea0291abb4b9703 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:13:11 +0200 Subject: [PATCH 09/15] feat(validation): own the Pipelex validate narrowing (resolves #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Pipelex-branded validation models into the SDK (pipelex_sdk/validation_models.py): PipelexValidationReport, PipelexInvalidReport, the PipelexValidationResult union + adapter, and the supporting ValidationErrorItem / ValidationErrorCategory / ValidatedPipeEntry / DryRunStatus — narrowing mthds's neutral verdict bases (mthds.protocol.models). PipelexAPIClient.validate() now parses the 200 body into PipelexValidationResult via the inherited _post_validate transport seam. Adds a local empty_list_factory_of util (pipelex_sdk/_pydantic_utils.py) and moves the validation-contract round-trip test here. Bumps the mthds floor to >=0.7.0 (the protocol-only-validate base). Completes the brand-layering follow-up #9, fully mirroring the documented pipelex-sdk-js boundary; the brand-neutral Dict* wire concretes stay in mthds and are reused by inheritance. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BkphvaUvNJi6G613jq1q7j --- CHANGELOG.md | 3 +- docs/architecture.md | 8 +- pipelex_sdk/_pydantic_utils.py | 15 ++ pipelex_sdk/client.py | 11 +- pipelex_sdk/errors.py | 3 +- pipelex_sdk/validation_models.py | 121 +++++++++++++++ pyproject.toml | 5 +- tests/unit/test_client_validate.py | 2 +- tests/unit/test_validation_contract.py | 199 +++++++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 356 insertions(+), 13 deletions(-) create mode 100644 pipelex_sdk/_pydantic_utils.py create mode 100644 pipelex_sdk/validation_models.py create mode 100644 tests/unit/test_validation_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a70ba7d..ce83806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipe - `start_and_wait` self-heals across hosted and bare runners: a cached `GET /v1/version` handshake picks the durable start+poll path on the hosted API and falls back to the blocking `POST /v1/execute` on a bare runner (including the case where a base-only version response hides a missing run store — `start` then surfaces `RunLifecycleUnavailableError` before any run is created). This closes a gap versus `mthds-python` (whose `start_and_wait` raises on a bare runner). - Lifecycle errors `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`; `RunStillRunningError` (the protocol `execute()` 202-degrade error) re-exported from `mthds` so all run/lifecycle errors share one import home. - Pipelex product surface (`pipelex_sdk/product_models.py` + client methods): the hosted management routes — user profile (`get_me`), methods catalog CRUD (`list_methods` / `get_method` / `create_method` / `update_method` / `delete_method`), organizations (`list_memberships` / `create_organization` / `rename_organization`), billing (`get_subscription` / `list_plans` / `list_invoices` / `create_checkout` / `change_plan` / `get_billing_portal`), Pipelex API keys (`list_pipelex_api_keys` / `create_pipelex_api_key` / `revoke_pipelex_api_key` / `rotate_pipelex_api_key`), the gateway inference key (`create_gateway_api_key` / `get_gateway_api_key`), onboarding (`submit_onboarding`), storage (`resolve_storage_url` / `upload`), and run records (`list_runs` / `update_run`). Documented `409`-conflict behaviors surface through `ApiResponseError.code` (`change_plan` / `get_billing_portal` ⇒ `conflict`; `create_pipelex_api_key` ⇒ `pipelex_api_key_limit_reached`). -- `validate` override + `validate_files`: the Pipelex-API `/v1/validate` surface — always injects `render: ["markdown"]` (so valid and invalid verdicts both carry `rendered_markdown`), accepts a parallel `mthds_sources` array, and `validate_files` synthesizes deterministic `inline://` source labels when any file carries a URI. +- Pipelex validation models (`pipelex_sdk/validation_models.py`): the SDK **owns** the Pipelex-branded narrowing of the `/v1/validate` 200-diagnostic union — `PipelexValidationReport`, `PipelexInvalidReport`, the `PipelexValidationResult` discriminated union + its `PipelexValidationResultAdapter`, and the supporting `ValidationErrorItem` / `ValidationErrorCategory` / `ValidatedPipeEntry` / `DryRunStatus`. These narrow the neutral protocol bases from `mthds.protocol.models`. (They previously lived in `mthds.runners.api.models`; moved here in lockstep with `mthds 0.7.0` to complete the brand boundary — `mthds`'s own `validate()` now returns the neutral `ValidationResult`. Mirrors `pipelex-sdk-js/src/models.ts`; resolves the brand-layering follow-up #9.) +- `validate` override + `validate_files`: the Pipelex-API `/v1/validate` surface — always injects `render: ["markdown"]` (so valid and invalid verdicts both carry `rendered_markdown`), accepts a parallel `mthds_sources` array, and `validate_files` synthesizes deterministic `inline://` source labels when any file carries a URI. Parses the 200 body into the SDK-owned `PipelexValidationResult` via the inherited `_post_validate` transport seam. - `health()`: the origin-level liveness probe — `GET {origin}/health`, served at the origin (NOT under the `/v1` prefix) and out-of-protocol. Rides the plainer `_request_json` regime (`PipelineRequestError` on a non-2xx, `ApiUnreachableError` on transport failure), not the product `ApiResponseError`. - `execute` override + `PipelineExecuteTimeoutError`: a blocking `POST /v1/execute` killed by the hosted gateway's ~30s synchronous ceiling (a `503`/`504`, or a client-side request timeout, observed at/after ~28s) is translated into a clear `PipelineExecuteTimeoutError` pointing at the durable start+poll path — closing a JS-parity gap (the inherited base `execute` does not do this). Every other non-2xx keeps the inherited `httpx.HTTPStatusError` regime, and the protocol's 202 async-degrade still raises `RunStillRunningError`. - `__version__` (`pipelex_sdk.version`), derived from the installed distribution metadata so it cannot drift from the `pyproject.toml` source of truth, with a test asserting the two match. diff --git a/docs/architecture.md b/docs/architecture.md index a610cc1..52c94cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,9 @@ mthds.runners.api.client.MthdsAPIClient (protocol-only base: transport, body-b ## Brand boundary (MTHDS vs Pipelex) -MTHDS is the brand of the open standard (the language, the protocol). Pipelex is the brand of the hosted runtime/product. Artifacts that belong to the standard keep neutral, un-prefixed names; Pipelex branding is reserved for genuinely runtime/product-specific surfaces (the durable run lifecycle, the product routes, implementation envelopes). The five protocol routes and their models stay in `mthds`; everything Pipelex-specific lives here. +MTHDS is the brand of the open standard (the language, the protocol). Pipelex is the brand of the hosted runtime/product. Artifacts that belong to the standard keep neutral, un-prefixed names; Pipelex branding is reserved for genuinely runtime/product-specific surfaces (the durable run lifecycle, the product routes, implementation envelopes). The five protocol routes and their neutral models stay in `mthds`; everything Pipelex-specific lives here. + +The Pipelex narrowing of the `/v1/validate` verdict union is one such implementation envelope and lives here (`pipelex_sdk.validation_models`): `PipelexValidationReport` / `PipelexInvalidReport` / the `PipelexValidationResult` union, plus the supporting `ValidationErrorItem` / `ValidationErrorCategory` / `ValidatedPipeEntry` / `DryRunStatus`. They narrow the neutral `ValidationReport` / `InvalidValidationReport` / `ValidationResult` bases that `mthds` keeps (in `mthds.protocol.models`). The report/union types carry the `Pipelex` prefix; the supporting types stay neutrally named — branding the envelope, not the fields inside it. The brand-neutral `Dict*` wire concretes (`DictRunResultExecute` and friends) stay in `mthds` — they are a shared wire contract the `pipelex` runtime itself builds on — and this SDK reuses them by inheritance rather than redefining them (a deliberate divergence from `pipelex-sdk-js`, which duplicates both the `Dict*` and the `Pipelex*` types in its own `models.ts`). ## Credentials & configuration @@ -114,7 +116,7 @@ The protocol `validate` is **overridden** (not inherited) to add the two Pipelex - **`mthds_sources`** is a named parameter (parallel to `mthds_contents`) threaded onto each diagnostic's `source`; sent only when provided. - **`validate_files(files, …)`** takes `MthdsFile(content, uri?)` records. When any file carries a URI, every content gets a parallel source label — the named file's URI, or a deterministic `inline://file-N.mthds` for an unnamed sibling — so the server never sees a length-mismatched `mthds_sources`. -The override delegates the wire call to the inherited base `validate` (passing `render` / `mthds_sources` through the protocol's `extra` extension passthrough), so the body-building and transport stay shared; only the Pipelex presentation/sources concerns live here. The validation models (`PipelexValidationResult` = `PipelexValidationReport | PipelexInvalidReport`, with `rendered_markdown`) are reused from `mthds` for now (the brand-layering follow-up #9 — they would eventually migrate here to fully mirror the JS boundary). +The override reuses the inherited base transport seam `_post_validate` (which builds the body — passing `render` / `mthds_sources` through the protocol's `extra` extension passthrough — sends the request, and raises on a no-verdict non-2xx), then parses the raw 200 body into this SDK's own `PipelexValidationResult` via `PipelexValidationResultAdapter`. Body-building and transport stay shared with the base; only the Pipelex presentation/sources concerns and the branded narrowing live here. The validation models (`PipelexValidationResult` = `PipelexValidationReport | PipelexInvalidReport`, with `rendered_markdown`) are **owned by this SDK** (`pipelex_sdk.validation_models`); they narrow `mthds`'s neutral verdict bases, completing the brand boundary (the resolved follow-up #9). The base `MthdsAPIClient.validate()` returns the neutral `ValidationResult` instead. **Checkpoint-5 decision (validate error regime):** the delegation keeps the inherited `httpx.HTTPStatusError` regime on a *no-verdict* non-2xx, where the JS `validate` raises `ApiResponseError`. Kept as-is (deferred parity), because in both SDKs `validate`'s error regime matches the *other* protocol routes of that SDK — JS routes all raise `ApiResponseError`, Python protocol routes all inherit `httpx.HTTPStatusError` (decision #5). Making Python's `validate` alone raise `ApiResponseError` would make it inconsistent with `execute`/`start`/`models`/`version`, which is worse than the JS divergence. The verdict itself (valid/invalid) is always a 200 either way — only the no-verdict failure *presentation* differs. @@ -155,7 +157,7 @@ This SDK is a faithful port of the TypeScript `@pipelex/sdk` (`PipelexApiClient` **Methods** — full coverage: protocol (`execute`, `start`, `validate`, `validate_files`, `models`, `version`), durable lifecycle (`get_run_status`, `get_run_result`, `wait_for_result`, `start_and_wait`, the private `_supports_run_lifecycle` / `_execute_blocking`), the whole product surface (profile, methods CRUD, organizations, billing, Pipelex API keys, gateway key, onboarding, storage, run records), and `health`. -**Models** — full field-for-field match across the run-lifecycle types and the product wire models. Deliberate idiomatic ports (not gaps): milliseconds → seconds (`interval_seconds` / `timeout_seconds` / `elapsed_seconds`); the JS `AbortSignal` → Python `asyncio` cancellation (no `signal` field); JS inline string-unions promoted to `StrEnum`s (`OrgRole`, `PipeStatus`, the onboarding fields) with identical wire values; response models are `extra="allow"` for forward-compat. The validate/`Dict*` models are reused from `mthds` (brand-layering follow-up #9), not redefined here. +**Models** — full field-for-field match across the run-lifecycle types and the product wire models. Deliberate idiomatic ports (not gaps): milliseconds → seconds (`interval_seconds` / `timeout_seconds` / `elapsed_seconds`); the JS `AbortSignal` → Python `asyncio` cancellation (no `signal` field); JS inline string-unions promoted to `StrEnum`s (`OrgRole`, `PipeStatus`, the onboarding fields) with identical wire values; response models are `extra="allow"` for forward-compat. The Pipelex validation narrowing is **owned here** (`pipelex_sdk.validation_models`), narrowing `mthds`'s neutral verdict bases (the resolved follow-up #9); the brand-neutral `Dict*` wire concretes (`DictRunResultExecute`) are reused from `mthds` by inheritance — they are a shared wire contract the `pipelex` runtime also builds on — rather than duplicated as `pipelex-sdk-js` does. **Errors** — `ApiResponseError`, `ApiUnreachableError`, `PipelineExecuteTimeoutError`, `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError` are owned here; `RunStillRunningError` is re-exported from `mthds`. `ClientAuthenticationError` is **not** ported: it is a dormant export in the JS barrel (defined and exported but never raised by the client), and in Python it already lives in `mthds.runners.api.exceptions` — importable directly if ever needed, with no barrel here to re-export it through. diff --git a/pipelex_sdk/_pydantic_utils.py b/pipelex_sdk/_pydantic_utils.py new file mode 100644 index 0000000..a741341 --- /dev/null +++ b/pipelex_sdk/_pydantic_utils.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable + +T = TypeVar("T") + + +def empty_list_factory_of(_: type[T]) -> Callable[[], list[T]]: + def _factory() -> list[T]: + return [] + + return _factory diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 5843da5..838a96f 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -29,7 +29,6 @@ from mthds.config.credentials import load_credentials from mthds.protocol.exceptions import PipelineRequestError from mthds.runners.api.client import MthdsAPIClient -from mthds.runners.api.models import ValidationErrorItem from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic_core import to_json from typing_extensions import override @@ -71,6 +70,7 @@ RunStatus, WaitForResultOptions, ) +from pipelex_sdk.validation_models import PipelexValidationResultAdapter, ValidationErrorItem if TYPE_CHECKING: from mthds.protocol.models import RunResultStart @@ -78,7 +78,7 @@ from mthds.protocol.pipeline_inputs import PipelineInputs from mthds.protocol.stuff import StuffType from mthds.protocol.working_memory import WorkingMemoryAbstract - from mthds.runners.api.models import DictRunResultExecute, PipelexValidationResult + from mthds.runners.api.models import DictRunResultExecute from pipelex_sdk.product_models import ( MethodWriteInput, @@ -87,6 +87,7 @@ UploadInput, ) from pipelex_sdk.runs import RunResultState + from pipelex_sdk.validation_models import PipelexValidationResult # The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`. # The same paths are served by the Pipelex Hosted API (api.pipelex.com) and by a bare @@ -391,7 +392,11 @@ async def validate( # type: ignore[override] extra: dict[str, Any] = {"render": _with_validate_markdown_render(render)} if mthds_sources is not None: extra["mthds_sources"] = mthds_sources - return await super().validate(mthds_contents, allow_signatures, extra=extra) + # Reuse the inherited transport seam (`_post_validate`) for body-building + the wire call, + # then parse the 200-diagnostic body into this SDK's Pipelex-branded narrowing. The base's + # own `validate` parses the same body into the neutral `mthds` `ValidationResult`. + response = await self._post_validate(mthds_contents, allow_signatures, extra) + return PipelexValidationResultAdapter.validate_python(response.json()) async def validate_files( self, diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index 9ec872e..b7de7c5 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -31,9 +31,8 @@ from mthds.runners.api.exceptions import RunStillRunningError as RunStillRunningError # noqa: PLC0414 if TYPE_CHECKING: - from mthds.runners.api.models import ValidationErrorItem - from pipelex_sdk.runs import RunStatus + from pipelex_sdk.validation_models import ValidationErrorItem class ApiUnreachableError(PipelineRequestError): diff --git a/pipelex_sdk/validation_models.py b/pipelex_sdk/validation_models.py new file mode 100644 index 0000000..b3af35d --- /dev/null +++ b/pipelex_sdk/validation_models.py @@ -0,0 +1,121 @@ +"""Pipelex's narrowing of the MTHDS `POST /v1/validate` 200-diagnostic union. + +The MTHDS Protocol layer (`mthds.protocol.models`) declares the brand-neutral verdict +shapes: `ValidationReport` (`is_valid: true`), `InvalidValidationReport` (`is_valid: false`), +the `ValidationResult` discriminated union, and the neutral `ValidationDiagnostic` item. +The Pipelex runtime *narrows* those with its structural artifacts and its closed +`ValidationErrorCategory` vocabulary. + +These narrowings are **Pipelex-branded implementation envelopes**, so they live here in +`pipelex-sdk`, not in the brand-neutral `mthds` package — mirroring `pipelex-sdk-js/src/models.ts` +and the documented brand boundary (`docs/architecture.md` → "Brand boundary"). The report/union +types carry the `Pipelex` prefix; the supporting types (`DryRunStatus`, `ValidatedPipeEntry`, +`ValidationErrorCategory`, `ValidationErrorItem`) stay neutrally named — branding the envelope, +not the field names inside it. + +`PipelexAPIClient.validate()` returns this `PipelexValidationResult` (parsed via +`PipelexValidationResultAdapter`); the protocol base `MthdsAPIClient.validate()` returns the +neutral `mthds` `ValidationResult`. +""" + +from __future__ import annotations + +from typing import Annotated, Any, TypeAlias + +from mthds.protocol.models import InvalidValidationReport, ValidationDiagnostic, ValidationReport +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from pipelex_sdk._compat import StrEnum +from pipelex_sdk._pydantic_utils import empty_list_factory_of + + +class DryRunStatus(StrEnum): + """Per-pipe dry-run sweep outcome on `ValidatedPipeEntry.status`.""" + + SUCCESS = "SUCCESS" + FAILURE = "FAILURE" + SKIPPED = "SKIPPED" + + +class ValidationErrorCategory(StrEnum): + """The closed `validation_errors[].category` vocabulary (locked). + + Mirrors the single source of truth in the conformance suite + (`conformance/conformance/validation_contract.py`); keep in sync with it. + """ + + BLUEPRINT_VALIDATION = "blueprint_validation" + PIPE_FACTORY = "pipe_factory" + PIPE_VALIDATION = "pipe_validation" + DRY_RUN = "dry_run" + + +class ValidatedPipeEntry(BaseModel): + """One entry of `PipelexValidationReport.validated_pipes[]`.""" + + model_config = ConfigDict(extra="allow") + + pipe_ref: str + status: DryRunStatus + + +class ValidationErrorItem(ValidationDiagnostic): + """Pipelex's structured `validation_errors[]` item — narrows the protocol base. + + `category` narrows to the closed `ValidationErrorCategory` set; the locators are + populated per category and dropped from the wire when unset. Built by pipelex's + one shared builder, so the hosted `InvalidReport` and the agent-CLI envelope + cannot drift. + """ + + category: ValidationErrorCategory # pyright: ignore[reportIncompatibleVariableOverride] + error_type: str | None = None + pipe_code: str | None = None + concept_code: str | None = None + domain_code: str | None = None + source: str | None = None + field_path: str | None = None + field_name: str | None = None + missing_concept_code: str | None = None + variable_names: list[str] | None = None + declared_concepts: list[str] | None = None + + +class PipelexValidationReport(ValidationReport): + """The valid arm narrowed with pipelex's structural artifacts (`is_valid: true`).""" + + bundle_blueprint: dict[str, Any] = Field(default_factory=dict) + pipe_io_contracts: dict[str, Any] = Field(default_factory=dict) + graph_spec: Any = None + validated_pipes: list[ValidatedPipeEntry] = Field(default_factory=empty_list_factory_of(ValidatedPipeEntry)) + pending_signatures: list[str] = Field(default_factory=list) + is_runnable: bool = True + message: str = "" + mthds_contents: list[str] | None = None + rendered_markdown: str | None = None + """Opt-in Pipelex-API presentation extra: the server-rendered Markdown view of the verdict, + present only when the request asked for it (`render: ["markdown"]`); absent (None) otherwise.""" + + +class PipelexInvalidReport(InvalidValidationReport[ValidationErrorItem]): + """The invalid arm carrying pipelex's structured `validation_errors[]` (`is_valid: false`).""" + + rendered_markdown: str | None = None + """Opt-in Pipelex-API presentation extra: the server-rendered Markdown view of the invalid + verdict, present only when the request asked for it (`render: ["markdown"]`); absent otherwise.""" + + +PipelexValidationResult: TypeAlias = Annotated[ + PipelexValidationReport | PipelexInvalidReport, + Field(discriminator="is_valid"), +] +"""Pipelex's `POST /v1/validate` 200 response — discriminated on `is_valid`.""" + + +PipelexValidationResultAdapter: TypeAdapter[PipelexValidationResult] = TypeAdapter(PipelexValidationResult) # pylint: disable=invalid-name +"""The single parse path for a 200 `/validate` body — built once at import (TypeAdapter construction is expensive). + +Routes on the `is_valid` discriminant: a present `True` → `PipelexValidationReport`, a present +`False` → `PipelexInvalidReport`. A body missing/with-a-bad `is_valid` cannot be tagged and raises +`pydantic.ValidationError`, so a malformed 200 can never be mistaken for a valid verdict. +""" diff --git a/pyproject.toml b/pyproject.toml index c3a3978..d1b6643 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "mthds>=0.6.0", + "mthds>=0.7.0", "pydantic>=2.10.6,<3.0.0", "backports.strenum>=1.3.0 ; python_version < '3.11'", "typing-extensions>=4.0.0", @@ -49,7 +49,8 @@ Changelog = "https://github.com/Pipelex/pipelex-sdk-python/blob/main/CHANGELOG.m # For local development, resolve `mthds` from the sibling workspace checkout. # uv ignores [tool.uv.sources] when building/publishing the wheel, so the -# published package depends on `mthds>=0.6.0` from PyPI (the protocol-only base). +# published package depends on `mthds>=0.7.0` from PyPI (the protocol-only base, +# whose `/validate` returns the neutral verdict union this SDK narrows). [tool.uv.sources] mthds = { path = "../mthds-python", editable = true } diff --git a/tests/unit/test_client_validate.py b/tests/unit/test_client_validate.py index fb4a216..6795a22 100644 --- a/tests/unit/test_client_validate.py +++ b/tests/unit/test_client_validate.py @@ -7,10 +7,10 @@ import httpx import pytest from mthds.protocol.exceptions import PipelineRequestError -from mthds.runners.api.models import PipelexInvalidReport, PipelexValidationReport from pytest_mock import MockerFixture, MockType from pipelex_sdk.client import MthdsFile, PipelexAPIClient +from pipelex_sdk.validation_models import PipelexInvalidReport, PipelexValidationReport _BASE_URL = "http://localhost:8081" diff --git a/tests/unit/test_validation_contract.py b/tests/unit/test_validation_contract.py new file mode 100644 index 0000000..98ef7ba --- /dev/null +++ b/tests/unit/test_validation_contract.py @@ -0,0 +1,199 @@ +"""Contract round-trip tests for the 200-diagnostic `/validate` union. + +Pins the Pipelex validation wire models (`pipelex_sdk.validation_models`) against the +canonical example bodies from the protocol spec (`docs/specs/pipelex-mthds-protocol.md`, +`## Validation report union`). Mirrors `mthds-js/tests/unit/protocol/validation-contract.test.ts`: +parse a wire body at the boundary, discriminate on `is_valid`, and assert the narrowed arm. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError + +from pipelex_sdk.validation_models import ( + DryRunStatus, + PipelexInvalidReport, + PipelexValidationReport, + PipelexValidationResult, + PipelexValidationResultAdapter, + ValidationErrorCategory, +) + +# ── Canonical example bodies (verbatim shapes from the protocol spec) ───────── + +VALID_BODY: dict[str, Any] = { + "is_valid": True, + "bundle_blueprint": {"source": "contracts.mthds", "domain": "legal_contracts"}, + "pipe_io_contracts": { + "legal_contracts.summarize": { + "inputs": {"contract": {"concept_ref": "legal_contracts.Contract", "json_schema": {}}}, + "output": {"concept_ref": "legal_contracts.Summary", "multiplicity": "single"}, + } + }, + "validated_pipes": [{"pipe_ref": "legal_contracts.summarize", "status": "SUCCESS"}], + "pending_signatures": [], + "is_runnable": True, + "graph_spec": {"nodes": [], "edges": []}, + "mthds_contents": [""], + "message": "Validation succeeded.", +} + +INVALID_BODY: dict[str, Any] = { + "is_valid": False, + "validation_errors": [ + { + "category": "pipe_validation", + "error_type": "PipeValidationError", + "message": "Pipe references an unknown concept.", + "pipe_code": "summarize", + "concept_code": "Contractt", + "field_name": "output", + "source": "contracts.mthds", + } + ], + "pending_signatures": [], + "is_runnable": False, + "message": "Validation found errors.", +} + +DRY_RUN_BODY: dict[str, Any] = { + "is_valid": False, + "validation_errors": [{"category": "dry_run", "error_type": "DryRunError", "message": "Dry run failed: residual."}], + "pending_signatures": [], + "is_runnable": False, + "message": "Validation found errors.", +} + +BLUEPRINT_RESIDUAL_BODY: dict[str, Any] = { + "is_valid": False, + "validation_errors": [{"category": "blueprint_validation", "error_type": "TOMLDecodeError", "message": "Invalid TOML."}], + "pending_signatures": [], + "is_runnable": False, + "message": "Validation found errors.", +} + +PENDING_SIGNATURE_BODY: dict[str, Any] = { + "is_valid": True, + "bundle_blueprint": {"source": "draft.mthds"}, + "pipe_io_contracts": {}, + "validated_pipes": [], + "pending_signatures": ["pending_sig.draft_step"], + "is_runnable": False, + "graph_spec": None, + "message": "Validation succeeded.", +} + + +def _parse(body: dict[str, Any]) -> PipelexValidationResult: + """Parse a wire body through the real discriminated-union adapter — the exact parse path `PipelexAPIClient.validate()` uses.""" + return PipelexValidationResultAdapter.validate_python(body) + + +class TestValidationContract: + def test_valid_arm_carries_typed_artifacts(self) -> None: + """The valid arm parses to a typed report with structural artifacts.""" + report = _parse(VALID_BODY) + assert isinstance(report, PipelexValidationReport) + assert report.is_valid is True + assert report.is_runnable is True + assert report.bundle_blueprint["source"] == "contracts.mthds" + assert "legal_contracts.summarize" in report.pipe_io_contracts + assert report.validated_pipes[0].pipe_ref == "legal_contracts.summarize" + assert report.validated_pipes[0].status is DryRunStatus.SUCCESS + assert report.mthds_contents == [""] + + def test_invalid_arm_carries_structured_errors_without_artifacts(self) -> None: + """The invalid arm carries typed `validation_errors[]` and no structural artifacts.""" + report = _parse(INVALID_BODY) + assert isinstance(report, PipelexInvalidReport) + assert report.is_valid is False + assert report.is_runnable is False + item = report.validation_errors[0] + assert item.category is ValidationErrorCategory.PIPE_VALIDATION + assert item.pipe_code == "summarize" + assert item.concept_code == "Contractt" + assert item.field_name == "output" + assert item.source == "contracts.mthds" + # Structural artifacts do not exist on the invalid arm — not a field, not an extra. + assert "bundle_blueprint" not in (report.model_extra or {}) + assert "graph_spec" not in (report.model_extra or {}) + + def test_dry_run_residual_is_graph_level(self) -> None: + """A dry-run residual is one `dry_run` item with no `source` (graph-level).""" + report = _parse(DRY_RUN_BODY) + assert isinstance(report, PipelexInvalidReport) + item = report.validation_errors[0] + assert item.category is ValidationErrorCategory.DRY_RUN + assert item.error_type == "DryRunError" + assert item.source is None + + def test_blueprint_residual_has_no_source(self) -> None: + """A parse-level residual is one `blueprint_validation` item with no `source`.""" + report = _parse(BLUEPRINT_RESIDUAL_BODY) + assert isinstance(report, PipelexInvalidReport) + assert report.validation_errors[0].category is ValidationErrorCategory.BLUEPRINT_VALIDATION + assert report.validation_errors[0].source is None + + def test_pending_signatures_is_valid_but_not_runnable(self) -> None: + """Pending signatures ride a runnability fact on a VALID arm, never an error item.""" + report = _parse(PENDING_SIGNATURE_BODY) + assert isinstance(report, PipelexValidationReport) + assert report.is_valid is True + assert report.is_runnable is False + assert report.pending_signatures == ["pending_sig.draft_step"] + + def test_rendered_markdown_is_typed_on_both_arms_when_present(self) -> None: + """The opt-in `rendered_markdown` extra parses to a typed field on both verdict arms.""" + valid = _parse({**VALID_BODY, "rendered_markdown": "# Validation passed"}) + assert isinstance(valid, PipelexValidationReport) + assert valid.rendered_markdown == "# Validation passed" + invalid = _parse({**INVALID_BODY, "rendered_markdown": "# Validation failed"}) + assert isinstance(invalid, PipelexInvalidReport) + assert invalid.rendered_markdown == "# Validation failed" + + def test_rendered_markdown_is_none_when_absent(self) -> None: + """Default responses omit `rendered_markdown` — the typed field defaults to None on both arms.""" + valid = _parse(VALID_BODY) + assert isinstance(valid, PipelexValidationReport) + assert valid.rendered_markdown is None + invalid = _parse(INVALID_BODY) + assert isinstance(invalid, PipelexInvalidReport) + assert invalid.rendered_markdown is None + + def test_category_vocabulary_is_the_locked_set(self) -> None: + """The closed category set mirrors `conformance/.../validation_contract.py` (drift guard).""" + assert {category.value for category in ValidationErrorCategory} == { + "blueprint_validation", + "pipe_factory", + "pipe_validation", + "dry_run", + } + + def test_unknown_category_is_rejected(self) -> None: + """An out-of-vocabulary category fails validation — the enum is a closed set.""" + bad_body = {**INVALID_BODY, "validation_errors": [{"category": "made_up", "message": "x"}]} + with pytest.raises(ValidationError, match="made_up"): + PipelexValidationResultAdapter.validate_python(bad_body) + + @pytest.mark.parametrize( + "malformed_body", + [ + {}, # no discriminant at all + {"message": "x"}, # still no discriminant — must NOT be read as a valid verdict + {"is_valid": None}, # null discriminant cannot be tagged + {"is_valid": "false"}, # non-boolean discriminant cannot be tagged + {"is_valid": False, "message": "x"}, # invalid arm tagged, but required validation_errors missing + ], + ) + def test_malformed_200_body_raises_no_silent_valid(self, malformed_body: dict[str, Any]) -> None: + """A 200 body that can't be discriminated, or whose tagged arm misses a required field, raises. + + Regression guard for the silent-valid hole: the old hand-rolled `is_valid is False` check + treated any non-`False` discriminant (missing, null, anything) as valid. Routing through the + discriminated-union adapter makes a missing/bad discriminant a loud `ValidationError` instead. + """ + with pytest.raises(ValidationError): + PipelexValidationResultAdapter.validate_python(malformed_body) diff --git a/uv.lock b/uv.lock index 2f15833..4e565c0 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.6.0" +version = "0.7.0" source = { editable = "../mthds-python" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, From e072bb786c405290e73cda8e7c83457d0f7ddde2 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:29:45 +0200 Subject: [PATCH 10/15] ci: add GitHub Actions workflows mirroring mthds-python Mirror the mthds-python CI suite, adapted to the Pipelex org and the pipelex-sdk PyPI distribution: - PR gates across the Python 3.10-3.14 matrix: lint-check, tests-check, package-check, changelog-check, version-check, guard-branches, cla - publish.yml: build -> PyPI Trusted Publishing -> Sigstore-signed GitHub Release on push to main - Add root CLA.md (Pipelex/Evotis CLA) and docs/ci-cd.md - Align CHANGELOG.md to the `## [vX.Y.Z]` header convention the changelog/publish workflows key off Also pin the published mthds floor to >=0.6.0, dropping the local editable [tool.uv.sources] so the wheel resolves mthds from PyPI. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015rNPx1LVeHFCJWXPkicEWy --- .github/workflows/changelog-check.yml | 32 +++++ .github/workflows/cla.yml | 42 +++++++ .github/workflows/guard-branches.yml | 63 ++++++++++ .github/workflows/lint-check.yml | 65 ++++++++++ .github/workflows/package-check.yml | 27 ++++ .github/workflows/publish.yml | 172 ++++++++++++++++++++++++++ .github/workflows/tests-check.yml | 52 ++++++++ .github/workflows/version-check.yml | 74 +++++++++++ CHANGELOG.md | 10 +- CLA.md | 94 ++++++++++++++ docs/ci-cd.md | 36 ++++++ pyproject.toml | 19 +-- uv.lock | 27 +--- 13 files changed, 682 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/changelog-check.yml create mode 100644 .github/workflows/cla.yml create mode 100644 .github/workflows/guard-branches.yml create mode 100644 .github/workflows/lint-check.yml create mode 100644 .github/workflows/package-check.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/tests-check.yml create mode 100644 .github/workflows/version-check.yml create mode 100644 CLA.md create mode 100644 docs/ci-cd.md diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 0000000..26f9e5b --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,32 @@ +name: Changelog Version Check + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened] + +jobs: + check-changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check Changelog Version + run: | + # Get version from pyproject.toml + VERSION=$(grep -m 1 'version = ' pyproject.toml | cut -d '"' -f 2) + echo "Version from pyproject.toml: $VERSION" + + # Look for the version in the changelog + if ! grep -q "## \[v$VERSION\] -" CHANGELOG.md; then + echo "❌ Error: No changelog entry found for version v$VERSION" + echo "" + echo "The following versions are in the changelog:" + grep -E "^## \[v[0-9]+" CHANGELOG.md | head -10 + echo "" + echo "Please add a changelog entry: ## [v$VERSION] - YYYY-MM-DD" + exit 1 + else + echo "✅ Changelog entry found for version v$VERSION" + fi diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..4b48559 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,42 @@ +name: "CLA Assistant bot" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize] + +permissions: + actions: write + contents: read + pull-requests: write + statuses: write + +jobs: + CLAAssistant: + runs-on: ubuntu-latest + steps: + - name: Get GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.CLA_GH_APP_ID }} + private-key: ${{ secrets.CLA_GH_APP_PRIVATE_KEY }} + owner: Pipelex + repositories: | + cla-signatures + pipelex-sdk-python + + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + uses: contributor-assistant/github-action@v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} + with: + path-to-signatures: "signatures/version1/cla.json" + path-to-document: "https://github.com/Pipelex/pipelex-sdk-python/blob/main/CLA.md" + branch: main + allowlist: lchoquel,thomashebrard,bot* + remote-organization-name: Pipelex + remote-repository-name: cla-signatures + signed-commit-message: "$contributorName has signed the CLA in $owner/$repo#$pullRequestNo" diff --git a/.github/workflows/guard-branches.yml b/.github/workflows/guard-branches.yml new file mode 100644 index 0000000..93f7fba --- /dev/null +++ b/.github/workflows/guard-branches.yml @@ -0,0 +1,63 @@ +name: Guard branch flow +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +jobs: + # ─────────────────────────────────────────────────────────────── + # 1) Only release/vX.Y.Z → main + # ─────────────────────────────────────────────────────────────── + gate-main: + if: github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Verify source branch is a Release + env: + HEAD: ${{ github.event.pull_request.head.ref }} + run: | + echo "PR → main from $HEAD" + if [[ ! "$HEAD" =~ ^release\/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Only release/vX.Y.Z branches may merge into main." + exit 1 + fi + + # ─────────────────────────────────────────────────────────────── + # 2) Only work-branches → release/vX.Y.Z, pre-release/vX.Y.Z..., or dev + # ─────────────────────────────────────────────────────────────── + gate-release: + if: startsWith(github.event.pull_request.base.ref, 'release/v') || startsWith(github.event.pull_request.base.ref, 'pre-release/v') || github.event.pull_request.base.ref == 'dev' + runs-on: ubuntu-latest + steps: + - name: Verify source branch uses allowed prefix + env: + HEAD: ${{ github.event.pull_request.head.ref }} + run: | + echo "PR → ${{ github.event.pull_request.base.ref }} from $HEAD" + if [[ "$HEAD" == "dev" ]]; then + exit 0 + fi + if [[ ! "$HEAD" =~ ^(fix|feature|refactor|chore|docs|ci-cd|changelog|codex)\/[A-Za-z0-9._\/\<\>\=\-]+$ ]]; then + echo "::error::Branch must start with fix/, feature/, refactor/, chore/, docs/, or ci-cd/." + exit 1 + fi + + # ─────────────────────────────────────────────────────────────── + # 3) Prevent forks from editing your workflows + # ─────────────────────────────────────────────────────────────── + protect-workflows: + runs-on: ubuntu-latest + # only block non-maintainers + if: github.event.pull_request.author_association == 'CONTRIBUTOR' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect workflow changes + run: | + git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 + CHANGED=$(git diff --name-only FETCH_HEAD HEAD | grep -E '^\.github/workflows/.*\.ya?ml$' || true) + if [ -n "$CHANGED" ]; then + echo "::error::External contributors may not modify workflow files: $CHANGED" + exit 1 + fi diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml new file mode 100644 index 0000000..0e9a559 --- /dev/null +++ b/.github/workflows/lint-check.yml @@ -0,0 +1,65 @@ +name: Lint check + +on: + pull_request: + +jobs: +# -------------------------------------------------------------------------- +# 1. Matrix job — one runner *per* Python version +# -------------------------------------------------------------------------- + lint: + name: Lint (${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + env: + VIRTUAL_ENV: ${{ github.workspace }}/.venv + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Check UV installation + run: make check-uv + + - name: Verify UV installation + run: uv --version + + - name: Install dependencies + run: PYTHON_VERSION=${{ matrix.python-version }} TEST_PROFILE=ci make install + + - name: Run ruff format merge check + run: make merge-check-ruff-format + + - name: Run ruff lint merge check + run: make merge-check-ruff-lint + + - name: Run pyright merge check + run: make merge-check-pyright + + - name: Run mypy merge check + run: make merge-check-mypy + +# -------------------------------------------------------------------------- +# 2. Aggregator job — the *single* required status check +# -------------------------------------------------------------------------- + lint-all: + name: Lint (all versions) + runs-on: ubuntu-latest + needs: lint # wait for every matrix leg + if: always() # run even if one leg already failed + + steps: + - name: Fail if any matrix leg failed + run: | + if [ "${{ needs.lint.result }}" != "success" ]; then + echo "::error::At least one Python version failed linting." + exit 1 + fi + echo "✅ All Python versions passed lint checks." diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml new file mode 100644 index 0000000..b01217f --- /dev/null +++ b/.github/workflows/package-check.yml @@ -0,0 +1,27 @@ +name: package-check + +on: + pull_request: + +jobs: + uv-lock-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Check if uv.lock is up to date + run: | + uv lock --locked + if ! git diff --exit-code uv.lock; then + echo "❌ uv.lock is out of date!" + echo "Please run 'uv lock' to update the lock file." + exit 1 + else + echo "✅ uv.lock is up to date!" + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3cab845 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,172 @@ +name: Publish Python 🐍 distribution 📦 to PyPI + +on: + push: + branches: + - main + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pipelex-sdk + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: >- + Create GitHub Release with Changelog + needs: + - build + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Extract version and detect pre-release + id: get_version + run: | + VERSION=$(grep -m 1 'version = ' pyproject.toml | cut -d '"' -f 2) + echo "VERSION=$VERSION" >> $GITHUB_ENV + + # Detect if version is a pre-release (PEP 440: contains a, b, or rc) + if [[ "$VERSION" =~ (a|b|rc)[0-9]+$ ]]; then + echo "IS_PRERELEASE=true" >> $GITHUB_ENV + echo "Detected pre-release version: $VERSION" + else + echo "IS_PRERELEASE=false" >> $GITHUB_ENV + echo "Detected stable version: $VERSION" + fi + - name: Extract changelog notes for current version + id: get_changelog + run: | + VERSION="${{ env.VERSION }}" + echo "Extracting changelog for version v$VERSION" + + # Find the start of the current version section + START_LINE=$(grep -n "## \[v$VERSION\] - " CHANGELOG.md | cut -d: -f1) + + if [ -z "$START_LINE" ]; then + echo "Warning: No changelog entry found for version v$VERSION" + echo "CHANGELOG_NOTES=" >> $GITHUB_ENV + exit 0 + fi + + # Find the start of the next version section (previous version) + NEXT_VERSION_LINE=$(tail -n +$((START_LINE + 1)) CHANGELOG.md | grep -n "^## \[v.*\] - " | head -1 | cut -d: -f1) + + if [ -z "$NEXT_VERSION_LINE" ]; then + # No next version found, extract from current version till end of file + CHANGELOG_CONTENT=$(tail -n +$START_LINE CHANGELOG.md) + else + # Extract content from current version header to before next version + END_LINE=$((START_LINE + NEXT_VERSION_LINE - 1)) + CHANGELOG_CONTENT=$(sed -n "$START_LINE,$((END_LINE - 1))p" CHANGELOG.md) + fi + + # Clean up the content but preserve the blank line after the header + # First, get the header line and add a blank line after it + HEADER_LINE=$(echo "$CHANGELOG_CONTENT" | head -1) + CONTENT_LINES=$(echo "$CHANGELOG_CONTENT" | tail -n +2 | sed '/^$/d' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + + # Combine header + blank line + content + CHANGELOG_CONTENT=$(printf "%s\n\n%s" "$HEADER_LINE" "$CONTENT_LINES") + + # Escape for GitHub Actions + echo "CHANGELOG_NOTES<> $GITHUB_ENV + echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + # Set pre-release flag if version is a pre-release + PRERELEASE_FLAG="" + TITLE_SUFFIX="" + if [ "$IS_PRERELEASE" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + TITLE_SUFFIX=" (Pre-release)" + fi + + if [ -n "$CHANGELOG_NOTES" ]; then + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --title "v$VERSION$TITLE_SUFFIX" \ + --generate-notes \ + --notes "$CHANGELOG_NOTES" \ + $PRERELEASE_FLAG + else + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --title "v$VERSION$TITLE_SUFFIX" \ + --generate-notes \ + --notes "Release v$VERSION" \ + $PRERELEASE_FLAG + fi + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + "v$VERSION" dist/** + --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/tests-check.yml b/.github/workflows/tests-check.yml new file mode 100644 index 0000000..0ff0a69 --- /dev/null +++ b/.github/workflows/tests-check.yml @@ -0,0 +1,52 @@ +name: Tests check + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: +# -------------------------------------------------------------------------- +# 1. Test matrix — one job per supported Python version +# -------------------------------------------------------------------------- + matrix-test: + name: Tests (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + permissions: + contents: read + id-token: write + env: + VIRTUAL_ENV: ${{ github.workspace }}/.venv + ENV: dev + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: PYTHON_VERSION=${{ matrix.python-version }} make install + + - name: Run tests + run: make gha-tests + +# -------------------------------------------------------------------------- +# 2. Aggregator job — the *single* required status check +# -------------------------------------------------------------------------- + tests-all: + name: Tests (all) + runs-on: ubuntu-latest + needs: [matrix-test] + if: always() + + steps: + - name: Fail if any test job failed + run: | + if [ "${{ needs.matrix-test.result }}" != "success" ]; then + echo "::error::At least one test job failed." + exit 1 + fi + echo "All test jobs passed." diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 0000000..1980353 --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,74 @@ +name: Version check + +on: + pull_request: + branches: + - main +jobs: + version-check: + runs-on: ubuntu-latest + steps: + - name: Get branch info + id: branch_info + run: | + echo "====== DEBUGGING BRANCH INFO ======" + echo "PR URL: ${{ github.event.pull_request.html_url }}" + echo "PR Number: ${{ github.event.pull_request.number }}" + + BASE_BRANCH="${{ github.event.pull_request.base.ref }}" + SOURCE_BRANCH="${{ github.event.pull_request.head.ref }}" + echo "Source branch: $SOURCE_BRANCH" + echo "Target branch: $BASE_BRANCH" + + echo "base_branch=$BASE_BRANCH" >> $GITHUB_OUTPUT + echo "source_branch=$SOURCE_BRANCH" >> $GITHUB_OUTPUT + + # Check if source is a release branch + if [[ "$SOURCE_BRANCH" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Source is a release branch" + echo "is_release_source=true" >> $GITHUB_OUTPUT + echo "source_release_version=${SOURCE_BRANCH#release/v}" >> $GITHUB_OUTPUT + echo "Extracted source release version: ${SOURCE_BRANCH#release/v}" + else + echo "Source is NOT a release branch - skipping version check" + echo "is_release_source=false" >> $GITHUB_OUTPUT + echo "This workflow only runs for PRs from release branches to main" + exit 0 + fi + echo "=======================================" + + - name: Checkout repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Get version from pyproject.toml + id: current_version + run: | + echo "====== DEBUGGING CURRENT VERSION ======" + VERSION=$(grep '^version' pyproject.toml | sed -E 's/version = "(.*)"/\1/') + echo "Current pyproject.toml version: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "=======================================" + + # Check that version in pyproject.toml matches the source release branch name + - name: Check version matches release branch + run: | + echo "====== CHECKING VERSION MATCH FOR RELEASE BRANCH ======" + # Extract version from branch name + RELEASE_VERSION="${{ steps.branch_info.outputs.source_release_version }}" + + # Get version from PR branch + PR_VERSION="${{ steps.current_version.outputs.version }}" + + echo "Release branch version: $RELEASE_VERSION" + echo "pyproject.toml version: $PR_VERSION" + + # Check if versions match + if [[ "$PR_VERSION" != "$RELEASE_VERSION" ]]; then + echo "❌ ERROR: Version in pyproject.toml ($PR_VERSION) does not match release branch version ($RELEASE_VERSION)" + exit 1 + else + echo "✅ Version in pyproject.toml ($PR_VERSION) matches release branch version ($RELEASE_VERSION)" + fi + echo "=======================================" diff --git a/CHANGELOG.md b/CHANGELOG.md index ce83806..c47bcbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,15 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke ## [Unreleased] -## [0.1.0] - 2026-06-30 +### Added + +- GitHub Actions CI/CD mirroring `mthds-python`, adapted to the `Pipelex` org and the `pipelex-sdk` PyPI distribution: PR gates (`lint-check`, `tests-check`, `package-check`, `changelog-check`, `version-check`, `guard-branches`, `cla`) across the full Python matrix, plus `publish.yml` (build → PyPI Trusted Publishing → signed GitHub Release) on push to `main`. Root `CLA.md` and `docs/ci-cd.md` added alongside. + +### Changed + +- `CHANGELOG.md` version headers now use the workspace-wide `## [vX.Y.Z]` convention (matching `mthds-python` / `pipelex-sdk-js`), which the changelog/publish workflows key off. + +## [v0.1.0] - 2026-06-30 The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipelex/sdk`, built by inheritance on the `mthds` protocol base. Surface-complete against the TypeScript SDK (see `docs/architecture.md` → "Parity with `@pipelex/sdk`"); the `/v1/build/*` helpers and the WorkOS org-switch are consciously out of scope for this release. diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..9991f30 --- /dev/null +++ b/CLA.md @@ -0,0 +1,94 @@ +# Pipelex (Evotis SAS) Grant and Contributor License Agreement (“Agreement”) + +This agreement is based on the Apache Software Foundation Contributor License +Agreement. + +Thank you for your interest in software projects stewarded by Pipelex +(Evotis SAS) (“Pipelex”). In order to clarify the intellectual property +license granted with Contributions from any person or entity, Pipelex must +have a Contributor License Agreement (CLA) on file that has been agreed to by +each Contributor, indicating agreement to the license terms below. This license +is for your protection as a Contributor as well as the protection of Pipelex +and its users; it does not change your rights to use your own Contributions for +any other purpose. This Agreement allows an individual to contribute to +Pipelex on that individual’s own behalf, or an entity (the “Corporation”) to +submit Contributions to Pipelex, to authorize Contributions submitted by its +designated employees to Pipelex, and to grant copyright and patent licenses +thereto. + +You accept and agree to the following terms and conditions for Your present and +future Contributions submitted to Pipelex. Except for the license granted +herein to Pipelex and recipients of software distributed by Pipelex, You +reserve all right, title, and interest in and to Your Contributions. + +1. Definitions. “You” (or “Your”) shall mean the copyright owner or legal + entity authorized by the copyright owner that is making this Agreement with + Pipelex. For legal entities, the entity making a Contribution and all + other entities that control, are controlled by, or are under common control + with that entity are considered to be a single Contributor. For the purposes + of this definition, “control” means (i) the power, direct or indirect, to + cause the direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + “Contribution” shall mean any work, as well as any modifications or + additions to an existing work, that is intentionally submitted by You to + Pipelex for inclusion in, or documentation of, any of the products owned + or managed by Pipelex (the “Work”, distributed under its own license). + For the purposes of this definition, “submitted” means any form of electronic, + verbal, or written communication sent to Pipelex or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems (such as GitHub), and issue tracking systems + that are managed by, or on behalf of, Pipelex for the purpose of discussing + and improving the Work, but excluding communication that is conspicuously + marked or otherwise designated in writing by You as “Not a Contribution.” + +2. Grant of Copyright License. Subject to the terms and conditions of this + Agreement, You hereby grant to Pipelex and to recipients of software + distributed by Pipelex a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable copyright license to reproduce, prepare derivative + works of, publicly display, publicly perform, sublicense, and distribute + Your Contributions and such derivative works. + +3. Grant of Patent License. Subject to the terms and conditions of this + Agreement, You hereby grant to Pipelex and to recipients of software + distributed by Pipelex a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable (except as stated in this section) patent license + to make, have made, use, offer to sell, sell, import, and otherwise transfer + the Work, where such license applies only to those patent claims licensable + by You that are necessarily infringed by Your Contribution(s) alone or by + combination of Your Contribution(s) with the Work to which such + Contribution(s) were submitted. If any entity institutes patent litigation + against You or any other entity (including a cross-claim or counterclaim in + a lawsuit) alleging that your Contribution, or the Work to which you have + contributed, constitutes direct or contributory patent infringement, then + any patent licenses granted to that entity under this Agreement for that + Contribution or Work shall terminate as of the date such litigation is + filed. + +4. You represent that You are legally entitled to grant the above license. If + You are an individual, and if Your employer(s) has rights to intellectual + property that you create that includes Your Contributions, you represent + that You have received permission to make Contributions on behalf of that + employer, or that Your employer has waived such rights for your + Contributions to Pipelex. If You are a Corporation, any individual who + makes a contribution from an account associated with You will be considered + authorized to Contribute on Your behalf. + +5. You represent that each of Your Contributions is Your original creation (see + section 7 for submissions on behalf of others). + +6. You are not expected to provide support for Your Contributions,except to the + extent You desire to provide support. You may provide support for free, for + a fee, or not at all. Unless required by applicable law or agreed to in + writing, You provide Your Contributions on an “AS IS” BASIS, WITHOUT + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, + without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, + MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +7. Should You wish to submit work that is not Your original creation, You may + submit it to Pipelex separately from any Contribution, identifying the + complete details of its source and of any license or other restriction + (including, but not limited to, related patents, trademarks, and license + agreements) of which you are personally aware, and conspicuously marking the + work as “Submitted on behalf of a third-party: [named here]”. + \ No newline at end of file diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..3c35220 --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,36 @@ +# CI/CD + +The GitHub Actions workflows under `.github/workflows/` mirror the `mthds-python` set, adapted to this repo's identity (the `Pipelex` GitHub org and the `pipelex-sdk` PyPI distribution). They split into PR gates that run on every pull request and a publish pipeline that runs when `main` advances. + +## PR gates + +| Workflow | Trigger | What it enforces | +| --- | --- | --- | +| `lint-check.yml` | `pull_request` | Runs `make merge-check-ruff-format`, `merge-check-ruff-lint`, `merge-check-pyright`, `merge-check-mypy` across the full Python matrix (3.10–3.14). A `lint-all` aggregator is the single required status check. | +| `tests-check.yml` | `pull_request` | Runs `make gha-tests` across the same matrix; `tests-all` aggregates. Concurrency-cancels superseded runs on the same branch. | +| `package-check.yml` | `pull_request` | `uv lock --locked` must leave `uv.lock` unchanged. | +| `changelog-check.yml` | `pull_request → main` | `CHANGELOG.md` must contain a `## [v] - …` entry matching `pyproject.toml`'s `version`. | +| `version-check.yml` | `pull_request → main` | For `release/vX.Y.Z` source branches, the `pyproject.toml` version must equal the branch's version. | +| `guard-branches.yml` | `pull_request_target` | Branch-flow policy: only `release/vX.Y.Z → main`; only `fix|feature|refactor|chore|docs|ci-cd|… → release/*`, `pre-release/*`, or `dev`; external contributors may not edit workflow files. | +| `cla.yml` | `pull_request_target`, `issue_comment` | Contributor License Agreement check against the `Pipelex/cla-signatures` registry. Points at this repo's root `CLA.md`. | + +The lint and test matrices use the repo `Makefile` targets, which honor `PYTHON_VERSION`, so each matrix leg provisions its own interpreter via `uv venv --python `. + +## Publish pipeline + +`publish.yml` runs on `push` to `main` (every merge of a `release/vX.Y.Z` PR, which `guard-branches.yml` is what restricts what can land there). Three sequential jobs: + +1. **build** — `python3 -m build` produces the sdist + wheel (`pipelex_sdk-.{tar.gz,whl}`), uploaded as an artifact. +2. **publish-to-pypi** — Trusted Publishing (OIDC, `id-token: write`) to PyPI via `pypa/gh-action-pypi-publish`. The `pypi` environment is pinned to `https://pypi.org/p/pipelex-sdk`. +3. **github-release** — extracts the current version's notes from `CHANGELOG.md`, Sigstore-signs the artifacts, creates the `v` GitHub Release (auto-flagged pre-release for PEP 440 `a`/`b`/`rc` suffixes), and uploads the signed artifacts. + +## Required org/repo configuration + +- **PyPI Trusted Publishing**: register `Pipelex/pipelex-sdk-python` as a trusted publisher for the `pipelex-sdk` project, environment `pypi`. No API token secret is needed. +- **CLA secrets** (org-level, shared with the other `Pipelex` Python repos): `CLA_GH_APP_ID`, `CLA_GH_APP_PRIVATE_KEY`. The GitHub App must have access to `cla-signatures` and this repo. + +## Release flow (summary) + +1. Branch `release/vX.Y.Z` off the integration branch; set `pyproject.toml` `version = "X.Y.Z"` and add a `## [vX.Y.Z] - YYYY-MM-DD` entry to `CHANGELOG.md`. +2. Open a PR into `main`. `version-check`, `changelog-check`, `lint-check`, `tests-check`, and `package-check` must pass. +3. Merge. The push to `main` triggers `publish.yml` → PyPI + a signed GitHub Release. diff --git a/pyproject.toml b/pyproject.toml index d1b6643..cf06f7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "mthds>=0.7.0", + "mthds>=0.6.0", "pydantic>=2.10.6,<3.0.0", "backports.strenum>=1.3.0 ; python_version < '3.11'", "typing-extensions>=4.0.0", @@ -47,13 +47,6 @@ Repository = "https://github.com/Pipelex/pipelex-sdk-python" Documentation = "https://github.com/Pipelex/pipelex-sdk-python" Changelog = "https://github.com/Pipelex/pipelex-sdk-python/blob/main/CHANGELOG.md" -# For local development, resolve `mthds` from the sibling workspace checkout. -# uv ignores [tool.uv.sources] when building/publishing the wheel, so the -# published package depends on `mthds>=0.7.0` from PyPI (the protocol-only base, -# whose `/validate` returns the neutral verdict union this SDK narrows). -[tool.uv.sources] -mthds = { path = "../mthds-python", editable = true } - [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] @@ -77,7 +70,15 @@ module = [ [tool.pyright] pythonVersion = "3.11" include = ["pipelex_sdk", "tests"] -exclude = ["**/__pycache__", ".venv", ".git", "build", "dist"] +exclude = [ + "**/__pycache__", + ".venv", + ".git", + "build", + "dist", + "**/node_modules", + "**/.*", +] analyzeUnannotatedFunctions = true deprecateTypingAliases = false disableBytesTypePromotions = true diff --git a/uv.lock b/uv.lock index 4e565c0..5b01465 100644 --- a/uv.lock +++ b/uv.lock @@ -250,8 +250,8 @@ wheels = [ [[package]] name = "mthds" -version = "0.7.0" -source = { editable = "../mthds-python" } +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, { name = "httpx" }, @@ -261,25 +261,10 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] - -[package.metadata] -requires-dist = [ - { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, - { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, - { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, - { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, - { name = "pyright", marker = "extra == 'dev'", specifier = "==1.1.408" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0,<9.0.0" }, - { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0,<4.0.0" }, - { name = "pytest-sugar", marker = "extra == 'dev'", specifier = ">=1.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.13" }, - { name = "semantic-version", specifier = ">=2.10.0,<3.0.0" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0,<3.0.0" }, - { name = "tomlkit", specifier = ">=0.12.0" }, - { name = "typing-extensions", specifier = ">=4.0.0" }, +sdist = { url = "https://files.pythonhosted.org/packages/51/d4/64263f35f42dcc0a74bb348e6511e923fe32f99d851955f04673f1cb5d68/mthds-0.6.0.tar.gz", hash = "sha256:8608599345f90b1a6f4a475cc73dc050d9dfd69c4b9245d814b8733fd923dd5b", size = 131133, upload-time = "2026-06-30T12:50:51.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f3/4b30d61dbd6b5699954da41908353524b07c388376a0bb164e75196bd6c0/mthds-0.6.0-py3-none-any.whl", hash = "sha256:8faf7ba78e672da7c3693a07872bfd654fcdcff43d0e085a9d340d9920beb2f3", size = 57703, upload-time = "2026-06-30T12:50:50.57Z" }, ] -provides-extras = ["dev"] [[package]] name = "mypy" @@ -390,7 +375,7 @@ dev = [ requires-dist = [ { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mthds", editable = "../mthds-python" }, + { name = "mthds", specifier = ">=0.6.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, From caef2736a486d889370e62d541a7e4ed4f0b0141 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:04:08 +0200 Subject: [PATCH 11/15] fix: address PR #1 review-agent findings (token anonymity + CI guards) - client.py: honor explicit `api_token=""` as anonymous by testing `is not None` rather than truthiness, so the first present credential layer wins. Restores the documented "empty = anonymous" contract and JS SDK `??` parity (codex P2). Adds regression tests. - guard-branches.yml: check out the PR head SHA so the workflow-protection diff actually sees fork edits, and gate on a maintainer allow-list so FIRST_TIME_CONTRIBUTOR / FIRST_TIMER / NONE authors are covered (codex + greptile P1). - tests-check.yml: drop unused `id-token: write` from the untrusted PR test job (greptile P1, least privilege). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015rNPx1LVeHFCJWXPkicEWy --- .github/workflows/guard-branches.yml | 14 ++++++++++++-- .github/workflows/tests-check.yml | 3 ++- CHANGELOG.md | 6 ++++++ pipelex_sdk/client.py | 16 ++++++++++++---- tests/unit/test_client_construction.py | 15 +++++++++++++++ 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/.github/workflows/guard-branches.yml b/.github/workflows/guard-branches.yml index 93f7fba..ac218ec 100644 --- a/.github/workflows/guard-branches.yml +++ b/.github/workflows/guard-branches.yml @@ -46,12 +46,22 @@ jobs: # ─────────────────────────────────────────────────────────────── protect-workflows: runs-on: ubuntu-latest - # only block non-maintainers - if: github.event.pull_request.author_association == 'CONTRIBUTOR' + # Block everyone except trusted maintainers. An allow-list (rather than singling out + # 'CONTRIBUTOR') is required because external authors also surface as + # FIRST_TIME_CONTRIBUTOR / FIRST_TIMER / NONE — those must not slip past the guard. + if: >- + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'COLLABORATOR' steps: - name: Checkout code uses: actions/checkout@v4 with: + # In pull_request_target the default checkout is the BASE branch; without this the + # diff below would compare base-against-base and never see the fork's changes. We + # only fetch/diff/grep here — the untrusted head is never executed — so checking + # out the PR head SHA is safe. + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Detect workflow changes run: | diff --git a/.github/workflows/tests-check.yml b/.github/workflows/tests-check.yml index 0ff0a69..9f756fd 100644 --- a/.github/workflows/tests-check.yml +++ b/.github/workflows/tests-check.yml @@ -18,9 +18,10 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + # Least privilege: this job runs untrusted PR code and never uses OIDC, so it must not + # be able to mint an id-token. Read-only contents is all the test steps need. permissions: contents: read - id-token: write env: VIRTUAL_ENV: ${{ github.workspace }}/.venv ENV: dev diff --git a/CHANGELOG.md b/CHANGELOG.md index c47bcbd..a356d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Ke - `CHANGELOG.md` version headers now use the workspace-wide `## [vX.Y.Z]` convention (matching `mthds-python` / `pipelex-sdk-js`), which the changelog/publish workflows key off. +### Fixed + +- `PipelexAPIClient` now honors an explicit `api_token=""` as a request for anonymous access even when `PIPELEX_API_KEY` (or an mthds credential) is configured. Credential resolution tests `is not None` rather than truthiness, so the first *present* layer wins, restoring the documented "empty string = anonymous" contract and matching the JS SDK's `??` precedence chain. +- `guard-branches.yml` workflow-protection job now checks out the PR head (`ref: pull_request.head.sha`) instead of the base branch, so its `git diff` actually detects fork edits to `.github/workflows/*`; its author-association gate is now a maintainer allow-list, so external authors reported as `FIRST_TIME_CONTRIBUTOR` / `FIRST_TIMER` / `NONE` (not only `CONTRIBUTOR`) are covered. +- `tests-check.yml` no longer grants `id-token: write` to the test matrix job, which runs untrusted PR code and never uses OIDC (least privilege). + ## [v0.1.0] - 2026-06-30 The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipelex/sdk`, built by inheritance on the `mthds` protocol base. Surface-complete against the TypeScript SDK (see `docs/architecture.md` → "Parity with `@pipelex/sdk`"); the `/v1/build/*` helpers and the WorkOS org-switch are consciously out of scope for this release. diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 838a96f..46d51e2 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -159,10 +159,18 @@ def __init__(self, api_token: str | None = None, api_base_url: str | None = None credentials = load_credentials() # Pipelex-primary, mthds fallback. `credentials` already layers env (MTHDS_*) > - # file (~/.mthds/config) > default, so this `or` chain gives the full precedence: - # explicit arg > PIPELEX_* env > MTHDS_* env > file > default. Empty string ("") - # means anonymous — the token is optional. - self.api_token: str = api_token or os.environ.get(_PIPELEX_API_KEY_ENV) or credentials["api_key"] + # file (~/.mthds/config) > default, so this ladder gives the full precedence: + # explicit arg > PIPELEX_* env > MTHDS_* env > file > default. The token is optional + # and an empty string ("") means anonymous — so the first layer that is *present* + # wins even when it is empty. We test `is not None` (not truthiness) to honor an + # explicit `api_token=""` / `PIPELEX_API_KEY=""`, matching the JS SDK's `??` chain. + self.api_token: str + if api_token is not None: + self.api_token = api_token + elif (pipelex_env_token := os.environ.get(_PIPELEX_API_KEY_ENV)) is not None: + self.api_token = pipelex_env_token + else: + self.api_token = credentials["api_key"] resolved_base_url = api_base_url or os.environ.get(_PIPELEX_API_URL_ENV) or credentials["api_url"] or DEFAULT_API_BASE_URL normalized_base_url = resolved_base_url.rstrip("/") diff --git a/tests/unit/test_client_construction.py b/tests/unit/test_client_construction.py index 9973775..d51adaa 100644 --- a/tests/unit/test_client_construction.py +++ b/tests/unit/test_client_construction.py @@ -49,6 +49,21 @@ def test_explicit_args_override_env_and_credentials(self, mocker: MockerFixture) assert client.api_token == "arg-token" assert client.api_base_url == "https://arg.example.com" + def test_explicit_empty_token_forces_anonymous_over_env(self, mocker: MockerFixture) -> None: + """An explicit `api_token=""` means anonymous and must win over a configured env token.""" + mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-env"}, clear=True) + client = PipelexAPIClient(api_token="") + assert client.api_token == "" + + def test_explicit_empty_token_forces_anonymous_over_credentials(self, mocker: MockerFixture) -> None: + """An explicit `api_token=""` must win over an mthds credential token too.""" + mocker.patch( + "pipelex_sdk.client.load_credentials", + return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"}, + ) + client = PipelexAPIClient(api_token="") + assert client.api_token == "" + def test_strips_trailing_slash(self) -> None: client = PipelexAPIClient(api_base_url="https://api.pipelex.com/") assert client.api_base_url == "https://api.pipelex.com" From f4178624e8c330c738f5e65564d670f2b49ef69d Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:28:41 +0200 Subject: [PATCH 12/15] settings --- .vscode/extensions.json | 7 +++++++ .vscode/settings.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d95081f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "Pipelex.pipelex", + "charliermarsh.ruff", + "matangover.mypy" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b389f0e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "ruff.configuration": "--config=pyproject.toml", + "mypy.enabled": true, + "mypy.runUsingActiveInterpreter": true, + "search.exclude": { + ".mypy_cache/*": true, + }, + "files.exclude": { + "**/__pycache__": true, + ".mypy_cache": true, + ".pytest_cache": true, + ".ruff_cache": true + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "djlint.showInstallError": false, + "editor.formatOnSave": true, + "[toml]": { + "editor.defaultFormatter": "Pipelex.pipelex", + "editor.formatOnSave": true + }, +} \ No newline at end of file From 68e9cda68b30859f7619f6b9f167a5ceff1b5eb6 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:15:05 +0200 Subject: [PATCH 13/15] Release v0.1.0 --- CHANGELOG.md | 30 +++++++++++++----------------- pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a356d24..26ac00b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,29 +2,14 @@ All notable changes to `pipelex-sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Added - -- GitHub Actions CI/CD mirroring `mthds-python`, adapted to the `Pipelex` org and the `pipelex-sdk` PyPI distribution: PR gates (`lint-check`, `tests-check`, `package-check`, `changelog-check`, `version-check`, `guard-branches`, `cla`) across the full Python matrix, plus `publish.yml` (build → PyPI Trusted Publishing → signed GitHub Release) on push to `main`. Root `CLA.md` and `docs/ci-cd.md` added alongside. - -### Changed - -- `CHANGELOG.md` version headers now use the workspace-wide `## [vX.Y.Z]` convention (matching `mthds-python` / `pipelex-sdk-js`), which the changelog/publish workflows key off. - -### Fixed - -- `PipelexAPIClient` now honors an explicit `api_token=""` as a request for anonymous access even when `PIPELEX_API_KEY` (or an mthds credential) is configured. Credential resolution tests `is not None` rather than truthiness, so the first *present* layer wins, restoring the documented "empty string = anonymous" contract and matching the JS SDK's `??` precedence chain. -- `guard-branches.yml` workflow-protection job now checks out the PR head (`ref: pull_request.head.sha`) instead of the base branch, so its `git diff` actually detects fork edits to `.github/workflows/*`; its author-association gate is now a maintainer allow-list, so external authors reported as `FIRST_TIME_CONTRIBUTOR` / `FIRST_TIMER` / `NONE` (not only `CONTRIBUTOR`) are covered. -- `tests-check.yml` no longer grants `id-token: write` to the test matrix job, which runs untrusted PR code and never uses OIDC (least privilege). - -## [v0.1.0] - 2026-06-30 +## [v0.1.0] - 2026-07-01 The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipelex/sdk`, built by inheritance on the `mthds` protocol base. Surface-complete against the TypeScript SDK (see `docs/architecture.md` → "Parity with `@pipelex/sdk`"); the `/v1/build/*` helpers and the WorkOS org-switch are consciously out of scope for this release. ### Added - Initial repository scaffold: packaging (`pyproject.toml`), tooling (`Makefile`, ruff/pyright/mypy/pylint config mirroring `mthds-python`), and the empty `pipelex_sdk` package. +- GitHub Actions CI/CD mirroring `mthds-python`, adapted to the `Pipelex` org and the `pipelex-sdk` PyPI distribution: PR gates (`lint-check`, `tests-check`, `package-check`, `changelog-check`, `version-check`, `guard-branches`, `cla`) across the full Python matrix, plus `publish.yml` (build → PyPI Trusted Publishing → signed GitHub Release) on push to `main`. Root `CLA.md` and `docs/ci-cd.md` added alongside. - `PipelexAPIClient` (subclass of `mthds`'s `MthdsAPIClient`): Pipelex-branded construction (resolves `PIPELEX_API_KEY` / `PIPELEX_API_URL`, falling back to the `mthds` resolver; token optional for anonymous access; host-only base-URL validation; origin URL for `health`). - Transport extension layer: `_request_product` (typed `ApiResponseError` mapping, empty-body tolerant, PUT/PATCH/DELETE), `_request_json` (plainer error regime), transport-failure mapping to `ApiUnreachableError`, and the `problem+json` error-body parser. - Errors: `ApiResponseError` (with the RFC 9457 `code` discriminant) and `ApiUnreachableError`, both deriving from the protocol-base `PipelineRequestError`. @@ -37,3 +22,14 @@ The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipe - `health()`: the origin-level liveness probe — `GET {origin}/health`, served at the origin (NOT under the `/v1` prefix) and out-of-protocol. Rides the plainer `_request_json` regime (`PipelineRequestError` on a non-2xx, `ApiUnreachableError` on transport failure), not the product `ApiResponseError`. - `execute` override + `PipelineExecuteTimeoutError`: a blocking `POST /v1/execute` killed by the hosted gateway's ~30s synchronous ceiling (a `503`/`504`, or a client-side request timeout, observed at/after ~28s) is translated into a clear `PipelineExecuteTimeoutError` pointing at the durable start+poll path — closing a JS-parity gap (the inherited base `execute` does not do this). Every other non-2xx keeps the inherited `httpx.HTTPStatusError` regime, and the protocol's 202 async-degrade still raises `RunStillRunningError`. - `__version__` (`pipelex_sdk.version`), derived from the installed distribution metadata so it cannot drift from the `pyproject.toml` source of truth, with a test asserting the two match. + +### Changed + +- Requires `mthds>=0.6.1` (protocol base floor). +- `CHANGELOG.md` version headers use the workspace-wide `## [vX.Y.Z]` convention (matching `mthds-python` / `pipelex-sdk-js`), which the changelog/publish workflows key off. + +### Fixed + +- `PipelexAPIClient` honors an explicit `api_token=""` as a request for anonymous access even when `PIPELEX_API_KEY` (or an mthds credential) is configured. Credential resolution tests `is not None` rather than truthiness, so the first *present* layer wins, restoring the documented "empty string = anonymous" contract and matching the JS SDK's `??` precedence chain. +- `guard-branches.yml` workflow-protection job checks out the PR head (`ref: pull_request.head.sha`) instead of the base branch, so its `git diff` actually detects fork edits to `.github/workflows/*`; its author-association gate is a maintainer allow-list, so external authors reported as `FIRST_TIME_CONTRIBUTOR` / `FIRST_TIMER` / `NONE` (not only `CONTRIBUTOR`) are covered. +- `tests-check.yml` no longer grants `id-token: write` to the test matrix job, which runs untrusted PR code and never uses OIDC (least privilege). diff --git a/pyproject.toml b/pyproject.toml index cf06f7b..ffa9c9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "mthds>=0.6.0", + "mthds>=0.6.1", "pydantic>=2.10.6,<3.0.0", "backports.strenum>=1.3.0 ; python_version < '3.11'", "typing-extensions>=4.0.0", diff --git a/uv.lock b/uv.lock index 5b01465..e1fb27a 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.6.0" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, @@ -261,9 +261,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/d4/64263f35f42dcc0a74bb348e6511e923fe32f99d851955f04673f1cb5d68/mthds-0.6.0.tar.gz", hash = "sha256:8608599345f90b1a6f4a475cc73dc050d9dfd69c4b9245d814b8733fd923dd5b", size = 131133, upload-time = "2026-06-30T12:50:51.744Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/33/c274de1115b6cbe8ac3475327a622893070f1ca40d9f2d0172d6fcae62b9/mthds-0.6.1.tar.gz", hash = "sha256:3d6b93306708f0ef8971285cfab4d9a488fca364e5db7e63a7dba2d065070eef", size = 132504, upload-time = "2026-07-01T07:09:50.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f3/4b30d61dbd6b5699954da41908353524b07c388376a0bb164e75196bd6c0/mthds-0.6.0-py3-none-any.whl", hash = "sha256:8faf7ba78e672da7c3693a07872bfd654fcdcff43d0e085a9d340d9920beb2f3", size = 57703, upload-time = "2026-06-30T12:50:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/0538d1bd11067f60f86ad74dc4d36bcad81ff3b549f71b3290aebd3a9aeb/mthds-0.6.1-py3-none-any.whl", hash = "sha256:5cd39d2b6fc5b43c5967162d80178b050748502515eda26916c2b80e8f3f1f42", size = 57705, upload-time = "2026-07-01T07:09:49.256Z" }, ] [[package]] @@ -375,7 +375,7 @@ dev = [ requires-dist = [ { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mthds", specifier = ">=0.6.0" }, + { name = "mthds", specifier = ">=0.6.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, From 535d71841e6eb7ab1edffedec4d3191883d12faf Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:18:10 +0200 Subject: [PATCH 14/15] feat: add release workflow automation and update changelog format --- .claude/skills/release/SKILL.md | 133 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 - 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/release/SKILL.md diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..1d852a3 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,133 @@ +--- +name: release +description: > + Automates the pipelex-sdk-python release workflow: bumps the version in pyproject.toml, finalizes the CHANGELOG.md Unreleased section, runs quality checks, regenerates uv.lock, creates a release/vX.Y.Z branch, commits, pushes, and opens a PR to main. Use when user says "release", "cut a release", "bump version", "prepare a release", "make a release", "ship it", "create release branch", or any variation of shipping a new version of the pipelex-sdk Python package. The user can optionally provide changelog content inline when invoking the skill (e.g. "/release Added the storage routes"), which will be used as the changelog entry for this version. +--- + +# pipelex-sdk-python Release Workflow + +This skill handles the full release cycle for the `pipelex-sdk` Python package (import package `pipelex_sdk`, the `pipelex-sdk-python` repo). A release is a `release/vX.Y.Z` branch that PRs into `main`; merging to `main` triggers `publish.yml`, which builds the wheel, publishes it to PyPI as `pipelex-sdk` via Trusted Publishing (OIDC, no token), and creates a Sigstore-signed GitHub release from the changelog notes. + +## Files touched + +- **`pyproject.toml`** — the `version` field (line 3, under `[project]`) +- **`CHANGELOG.md`** — add `## [vX.Y.Z] - YYYY-MM-DD` entry (convert the `## [Unreleased]` section if present) +- **`uv.lock`** — regenerated via `make li` (lock + install) + +## Workflow + +### 1. Pre-flight checks + +- Read the current version from `pyproject.toml`. +- Read `CHANGELOG.md` to understand the current state (this repo keeps a `## [Unreleased]` section at the top). +- Run `git status` and `git log origin/main..HEAD` to assess the working tree: + - If there are **uncommitted changes** (staged or unstaged), warn the user and ask whether to commit them as part of the release, stash them, or abort. + - If there are **unpushed commits** on the current branch, list them so the user is aware — these will be included in the release branch. + +### 2. Determine the bump type + +Ask the user which kind of version bump they want — **patch**, **minor**, or **major** — unless they already specified it. Show the current version and what the new version would be for each option so the choice is concrete. + +While the package is pre-1.0 (`0.y.z`), treat the `0.MINOR.PATCH` segments the way the project has been using them: a breaking change bumps the minor, a backward-compatible feature or fix bumps the patch. If the changelog for this release contains a `### Breaking Changes` section (or otherwise describes a breaking change), steer the user toward at least a minor bump — this matches the repo's "pre-1.0 breaking changes → minor version bump" rule. + +### 3. Run quality checks + +Run `make agent-check`. This is the gate — if it fails, stop and report the errors so they can be fixed before retrying. Do not proceed past this step on failure. + +### 4. Ensure we're on the right branch + +The release branch must be named `release/vX.Y.Z` where X.Y.Z is the **new** version. The CI guards in this repo are strict about this: + +- `guard-branches.yml` (`gate-main`) rejects any source branch other than `release/vX.Y.Z` merging into `main`. +- `version-check.yml` rejects a mismatch between the branch name and the `pyproject.toml` version. + +Both guards match the **exact** regex `release/v[0-9]+\.[0-9]+\.[0-9]+` (strict three-segment semver, no suffix). All file modifications (changelog, version bump, lock) must happen on this branch. + +- If already on `release/vX.Y.Z` matching the new version, stay on it. +- If on `dev`, `main`, or any other branch, create and switch to `release/vX.Y.Z` from the current HEAD. +- If on a `release/` branch for a **different** version, warn the user and ask how to proceed. + +### 5. Finalize the changelog + +Add a new version entry for the release. This repo uses the workspace-wide `## [vX.Y.Z]` header convention (the changelog and publish workflows key off it). + +1. If there is an `## [Unreleased]` section, **convert it**: remove the `## [Unreleased]` heading (and any blank lines that immediately follow it) and replace it with the new `## [vX.Y.Z] - YYYY-MM-DD` heading. Any content that was under `[Unreleased]` becomes the content of the new version. +2. If there is no `[Unreleased]` section, insert the new version heading directly after the `# Changelog` intro block. +3. **Never recreate an `[Unreleased]` heading.** After a release the changelog should contain only concrete version entries — the next change adds a fresh `## [Unreleased]` section organically when someone starts the next cycle. +4. If the user provided changelog content when invoking the skill (e.g. `/release Added the storage routes`), **merge** that content with any existing `[Unreleased]` content (do not discard either source). Format the combined content under the appropriate headings — this repo uses `### Breaking Changes`, `### Added`, `### Changed`, `### Fixed`, `### Removed` — inferring headings from the content when possible. +5. If the release has no changelog content yet (neither from an `[Unreleased]` section nor from inline user input), ask the user what to include before proceeding. +6. The result should look like: + +```markdown +# Changelog + +All notable changes to `pipelex-sdk` are documented here. ... + +## [vX.Y.Z] - YYYY-MM-DD + +### Changed +- ... + +## [vPREVIOUS] - PREVIOUS-DATE +... +``` + +### 6. Bump the version in pyproject.toml + +Edit `pyproject.toml` line 3 (`version = "..."` under `[project]`) to the new version string. Only change the version field — don't touch anything else. + +### 7. Lock dependencies + +Run `make li` to regenerate `uv.lock` and reinstall. This ensures the lockfile reflects the new version in `pyproject.toml`. The `package-check.yml` CI job runs `uv lock --locked` and fails the PR if `uv.lock` is out of sync, so this step is not optional. If it fails, stop and report the error. + +### 8. Commit and push + +Stage all release-related changes. This includes at minimum `pyproject.toml`, `CHANGELOG.md`, and `uv.lock`, plus any other files the user chose to include in step 1 (e.g. previously uncommitted work that belongs in this release). + +Commit with the message: + +``` +Release vX.Y.Z +``` + +Push the branch to origin with `-u` to set up tracking. + +### 9. Open a PR + +Create a pull request targeting `main` with: + +- **Title:** `Release vX.Y.Z` +- **Body:** Include: + - The changelog entries for this version (copied from CHANGELOG.md) + - A note about the version bump from old to new + +Use this format for the PR body: + +```markdown +## Release vX.Y.Z + +Bumps version from `A.B.C` to `X.Y.Z`. + +### Changelog + + +``` + +Report the PR URL back to the user, and remind them that **merging the PR into `main` is what publishes** — `publish.yml` builds the wheel, pushes it to PyPI as `pipelex-sdk` (Trusted Publishing), and cuts the Sigstore-signed GitHub release automatically. Nothing publishes until the PR is merged. + +## Important details + +- The version follows semver: `MAJOR.MINOR.PATCH`. +- Always confirm the bump type with the user before making changes. +- If `make agent-check` fails, the release is blocked — help the user fix the issues rather than skipping the checks. +- The CI gates a `release/vX.Y.Z` → `main` PR with: + - `version-check.yml` — the `pyproject.toml` version must match the `release/vX.Y.Z` branch name. + - `changelog-check.yml` — `CHANGELOG.md` must contain a `## [vX.Y.Z] -` entry for the new version. + - `package-check.yml` — `uv.lock` must be in sync with `pyproject.toml` (`uv lock --locked`). + - `tests-check.yml` — the test matrix must pass on every supported Python version (3.10 through 3.14). + - `lint-check.yml` — ruff format, ruff lint, pyright, and mypy merge checks across the same Python matrix (the same gates as `make agent-check`). + - `guard-branches.yml` — only `release/vX.Y.Z` branches may target `main`. + - `cla.yml` — the PR author must have signed the Pipelex CLA (maintainers are allow-listed; an external first-time author will be prompted to sign before the PR can merge). +- All checks must pass for the PR to be mergeable, so getting the changelog, version, and lockfile right is critical. +- **Pre-release versions are not supported through this flow.** Unlike `mthds-python`, this repo's `guard-branches.yml` (`gate-main`) and `version-check.yml` both match the exact regex `release/v[0-9]+\.[0-9]+\.[0-9]+` — a PEP 440 suffix (`a`/`b`/`rc`, e.g. `0.2.0rc1`) on a `release/v0.2.0rc1` branch would be **rejected** by the branch guard even though `publish.yml` can detect pre-releases. Stick to strict three-segment versions for the `release/vX.Y.Z` → `main` flow; raise it with the user if they ask for a pre-release. +- Today's date for the changelog entry: use the current date in `YYYY-MM-DD` format. diff --git a/CHANGELOG.md b/CHANGELOG.md index 26ac00b..ffbb1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ # Changelog -All notable changes to `pipelex-sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## [v0.1.0] - 2026-07-01 The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipelex/sdk`, built by inheritance on the `mthds` protocol base. Surface-complete against the TypeScript SDK (see `docs/architecture.md` → "Parity with `@pipelex/sdk`"); the `/v1/build/*` helpers and the WorkOS org-switch are consciously out of scope for this release. From 642fe742bed264afa2aac0cacb31b29bf27621f8 Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:29:49 +0200 Subject: [PATCH 15/15] ci: harden guard-branches workflow protection Gate workflow-edit trust on the author's effective repository permission (resolved via getCollaboratorPermissionLevel, only write/admin trusted, failing closed on non-404 API errors) instead of the spoofable author_association label, and drop to least-privilege contents: read. Mirrors mthds-python. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015rNPx1LVeHFCJWXPkicEWy --- .github/workflows/guard-branches.yml | 41 +++++++++++++++++++++++----- CHANGELOG.md | 2 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/guard-branches.yml b/.github/workflows/guard-branches.yml index ac218ec..675a738 100644 --- a/.github/workflows/guard-branches.yml +++ b/.github/workflows/guard-branches.yml @@ -46,15 +46,41 @@ jobs: # ─────────────────────────────────────────────────────────────── protect-workflows: runs-on: ubuntu-latest - # Block everyone except trusted maintainers. An allow-list (rather than singling out - # 'CONTRIBUTOR') is required because external authors also surface as - # FIRST_TIME_CONTRIBUTOR / FIRST_TIMER / NONE — those must not slip past the guard. - if: >- - github.event.pull_request.author_association != 'OWNER' && - github.event.pull_request.author_association != 'MEMBER' && - github.event.pull_request.author_association != 'COLLABORATOR' + # Least privilege: querying the author's permission and diffing the head only needs read. + permissions: + contents: read steps: + # Trust must hinge on the author's EFFECTIVE repository permission, not author_association. + # author_association is a social label: an org MEMBER or a COLLABORATOR can hold read-only + # access, so an association allow-list would let a read-only insider's workflow edits slip + # past this guard. Resolve the real permission and treat only write/maintain/admin as trusted. + - name: Resolve author repository permission + id: perm + uses: actions/github-script@v7 + with: + script: | + const username = context.payload.pull_request.user.login; + let data = { permission: 'none', role_name: 'none' }; + try { + ({ data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + })); + } catch (error) { + // A 404 means the author is not a resolvable collaborator (deleted/renamed + // account, or no access) — treat as untrusted and let the workflow-diff + // check run. Re-throw anything else so a transient API failure fails closed. + if (error.status !== 404) throw error; + } + // The legacy `permission` field collapses roles: admin → "admin", + // maintain & write → "write", triage & read → "read", none → "none". + const trusted = data.permission === 'admin' || data.permission === 'write'; + core.info(`Author ${username}: permission=${data.permission} role=${data.role_name} trusted=${trusted}`); + core.setOutput('trusted', trusted ? 'true' : 'false'); + - name: Checkout code + if: steps.perm.outputs.trusted != 'true' uses: actions/checkout@v4 with: # In pull_request_target the default checkout is the BASE branch; without this the @@ -64,6 +90,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Detect workflow changes + if: steps.perm.outputs.trusted != 'true' run: | git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 CHANGED=$(git diff --name-only FETCH_HEAD HEAD | grep -E '^\.github/workflows/.*\.ya?ml$' || true) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbb1d0..b880b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,5 +29,5 @@ The initial public surface of `pipelex-sdk` — the Python counterpart of `@pipe ### Fixed - `PipelexAPIClient` honors an explicit `api_token=""` as a request for anonymous access even when `PIPELEX_API_KEY` (or an mthds credential) is configured. Credential resolution tests `is not None` rather than truthiness, so the first *present* layer wins, restoring the documented "empty string = anonymous" contract and matching the JS SDK's `??` precedence chain. -- `guard-branches.yml` workflow-protection job checks out the PR head (`ref: pull_request.head.sha`) instead of the base branch, so its `git diff` actually detects fork edits to `.github/workflows/*`; its author-association gate is a maintainer allow-list, so external authors reported as `FIRST_TIME_CONTRIBUTOR` / `FIRST_TIMER` / `NONE` (not only `CONTRIBUTOR`) are covered. +- `guard-branches.yml` workflow-protection job checks out the PR head (`ref: pull_request.head.sha`) instead of the base branch, so its `git diff` actually detects fork edits to `.github/workflows/*`; it gates trust on the author's **effective repository permission** (resolved via `getCollaboratorPermissionLevel`, treating only `write`/`admin` as trusted, failing closed on non-404 API errors) rather than the spoofable `author_association` label, and drops to least-privilege `permissions: contents: read`. Matches `mthds-python`. - `tests-check.yml` no longer grants `id-token: write` to the test matrix job, which runs untrusted PR code and never uses OIDC (least privilege).