From 05f691c9622b1b4a0fda2e28ed1166ac3c030803 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Wed, 30 Sep 2026 11:07:13 +0200 Subject: [PATCH 1/6] Add pipe_io() and move prepare_inputs onto POST /v1/pipe-io pipe_io() wraps the new crate route that returns a method's pipe I/O contracts, input form and output form with no dry run, typed from mthds.protocol, with the selector XOR enforced at request construction. prepare_inputs now reads its signature from that route, which selects the pipe: the client-side default chain and the bundle-blueprint read are gone, a bare pipe_ref is still refused before any request, and a 422 whose error_type is the runner's entry-lookup error becomes an InputPreparationError carrying the server's reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EWwzmAGtZvzvQqeC1SvzJ1 --- CHANGELOG.md | 10 + README.md | 20 +- docs/architecture.md | 14 +- docs/input-preparation.md | 32 +-- pipelex_sdk/client.py | 51 ++++- pipelex_sdk/crate_models.py | 86 +++++++- pipelex_sdk/prepare_inputs.py | 303 ++++++++++++-------------- tests/e2e/test_pipe_io_e2e.py | 222 +++++++++++++++++++ tests/unit/test_crate_routes.py | 22 +- tests/unit/test_data.py | 114 +++++++++- tests/unit/test_pipe_io_route.py | 195 +++++++++++++++++ tests/unit/test_prepare_inputs.py | 342 +++++++++++++++++------------- 12 files changed, 1057 insertions(+), 354 deletions(-) create mode 100644 tests/e2e/test_pipe_io_e2e.py create mode 100644 tests/unit/test_pipe_io_route.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b74b789..844ad3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Added + +- **`pipe_io()`, a method's inputs and outputs in one call**: `PipelexAPIClient.pipe_io(PipeIORequest(...))` calls `POST /v1/pipe-io` and returns, with no dry run, the selected pipe's `pipe_io_contracts`, `input_form` and `output_form` typed from `mthds.protocol`, beside the resolved `pipe_ref`, the method's `default_pipe_ref`, `pending_signatures` and `is_runnable` (`PipeIOValidReport`, or the `CrateInvalidReport` verdict). It takes `files`, `method_ref` or `method_id` like `resolve`, plus `pipe_ref`, `all_pipes` (every pipe instead of one) and `include_files` (echo the closure's `.mthds` files); a refused selection raises `ApiResponseError`. + +### Changed + +- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A refused selection raises `InputPreparationError` with the server's reason, and the route runs no dry run, so a method whose dry run fails still prepares. + ## [v0.14.0] - 2026-09-27 ### Changed diff --git a/README.md b/README.md index 3afbcc7..16fc79c 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,21 @@ report = await client.validate(method_ref="github.com/Pipelex/methods/documents@ report = await client.validate(method_id="mt_123") ``` +### Read a method's inputs and outputs + +`pipe_io()` returns a method's pipe I/O contracts, input form and output form in one call, without validating it (no dry run). The server selects the pipe — your `pipe_ref`, else the package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — and the three artifacts are the standard's own models from `mthds.protocol`: + +```python +from pipelex_sdk.crate_models import PipeIORequest, PipeIOValidReport + +report = await client.pipe_io(PipeIORequest(method_ref="github.com/Pipelex/methods/documents@v0.1.0")) +if isinstance(report, PipeIOValidReport): + descriptor = report.input_form[report.pipe_ref] + print([field.name for field in descriptor.fields], report.is_runnable) +``` + +`all_pipes=True` keys the three maps by every pipe instead, and `include_files=True` echoes the closure's `.mthds` files. `prepare_inputs` reads its signature from this route, so it needs an API that serves `POST /v1/pipe-io`. + ### Generate typed code into your project `codegen()` projects a method into stamped typed artifacts plus their `codegen.lock`, and `write_codegen_tree` writes that response to disk verbatim, so the tree is byte-identical to a local `pipelex codegen types` run and no `pipelex` install is needed: @@ -169,7 +184,7 @@ Branch on `error_domain` (`input`, `config`, `runtime`), `type_uri` and `retryab ### API errors: branch on `type_uri` and `error_domain`, not the HTTP status -Every `/v1` route raises a typed `ApiResponseError` on a non-2xx answer, carrying the members of the RFC 9457 problem document: the protocol routes (`execute`, `start`, `validate`, `models`, `version`), the run status and results reads, and the product routes — the account, methods, organization, billing, API-key, onboarding, storage and upload methods, `codegen` and `resolve`, and the run records (`list_runs`, `iterate_runs`, `get_run_detail`, `update_run`). It is `mthds`'s own `ApiResponseError` narrowed, so `except mthds.runners.api.exceptions.ApiResponseError` catches it too; `health` raises `PipelineRequestError`, and `docs/architecture.md` lists the error regimes. The branch fields are `type_uri`, the problem's `type`, a stable URI naming the error class that every problem carries, and `error_domain`, the coarse class (`input` means the caller can fix it, `config` that a configuration change is needed, `runtime` that execution failed). `error_domain` is carried only by the problems the runner renders — a run route's refusal, and those of `codegen` and `resolve`, which the hosted API relays from the runner — and is `None` on the platform's own problems, such as those of the account, billing and API-key routes, which name their class by `type_uri` alone: +Every `/v1` route raises a typed `ApiResponseError` on a non-2xx answer, carrying the members of the RFC 9457 problem document: the protocol routes (`execute`, `start`, `validate`, `models`, `version`), the run status and results reads, and the product routes — the account, methods, organization, billing, API-key, onboarding, storage and upload methods, `codegen`, `resolve` and `pipe_io`, and the run records (`list_runs`, `iterate_runs`, `get_run_detail`, `update_run`). It is `mthds`'s own `ApiResponseError` narrowed, so `except mthds.runners.api.exceptions.ApiResponseError` catches it too; `health` raises `PipelineRequestError`, and `docs/architecture.md` lists the error regimes. The branch fields are `type_uri`, the problem's `type`, a stable URI naming the error class that every problem carries, and `error_domain`, the coarse class (`input` means the caller can fix it, `config` that a configuration change is needed, `runtime` that execution failed). `error_domain` is carried only by the problems the runner renders — a run route's refusal, and those of `codegen`, `resolve` and `pipe_io`, which the hosted API relays from the runner — and is `None` on the platform's own problems, such as those of the account, billing and API-key routes, which name their class by `type_uri` alone: ```python from pipelex_sdk.errors import ApiResponseError @@ -198,12 +213,13 @@ There is no barrel import — package `__init__.py` files stay empty. Import eac - **Error reports** — `from pipelex_sdk.error_models import RunErrorReport, UserAction, ProviderErrorMetadata, MigrationErrorBlock, FieldError` - **Product wire models** — `from pipelex_sdk.product_models import UserProfile, MethodData, MethodWriteInput, Membership, MembershipsResponse, SubscriptionResponse, PlanView, InvoiceView, OnboardingSubmission, UploadInput, UploadedFile, PipelineRun, ...`, with the catalog-source readers beside them: `method_source_to_contents` turns a fetched `MethodData.mthds` into the `mthds_contents` a run or a validate takes, and `MethodFile` / `parse_method_files` / `serialize_method_files` are the codec for a method's custom PipeFunc `python`. - **Validation verdict types** — `from pipelex_sdk.validation_models import PipelexValidationResult, PipelexValidationReport, PipelexInvalidReport, ValidationErrorItem, SuggestedFix, VALIDATION_VIEW_INPUT_FORM, ...` +- **Crate routes** — `from pipelex_sdk.crate_models import ResolveRequest, CodegenRequest, PipeIORequest, PipeIOValidReport, CrateInvalidReport, MthdsFileItem, ...`, the requests and the two 200 arms of `resolve`, `codegen` and `pipe_io` - **Codegen tree** — `from pipelex_sdk.codegen_writer import write_codegen_tree, CodegenTreeWriteReport` to write one, `from pipelex_sdk.codegen_check import run_codegen_check, CodegenCheckReport, CodegenDrift, DriftCategory` to verify one, with the format primitives in `pipelex_sdk.codegen_lock` (`CodegenLock`, `parse_lock`, `load_lock`, `validate_artifact_path`, ...) and `pipelex_sdk.codegen_stamp` (`STAMPABLE_SUFFIXES`, `is_stampable_artifact_path`, `compute_content_hash`, `parse_stamped`, ...) - **Typed errors** — `from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError, PipelineExecuteTimeoutError, PagingNotTerminatingError, RunFailedError, RunTimeoutError, RunLifecycleUnavailableError, RunStillRunningError, CodegenError, CodegenLockError, ...` - **Version** — `from pipelex_sdk.version import __version__` - **Client identification** — `from pipelex_sdk.user_agent import AppInfo, build_user_agent, is_token` - **Protocol surface** (the MTHDS standard's wire types) comes from the `mthds` dependency — e.g. `from mthds.protocol.exceptions import PipelineRequestError`, `from mthds.protocol.models import ValidationResult` (the neutral verdict union that `PipelexValidationResult` narrows). -- **Input-form descriptors and pipe I/O contracts** come from `mthds` too, because they are the standard's artifacts and this SDK only carries them: `from mthds.protocol.input_form import InputForm, InputFormField, ListField, TextField, ...` and `from mthds.protocol.pipe_io_contracts import PipeIOContracts, PipeInputContract, PresenceMarker, IOMultiplicity, ...`. `PipelexValidationReport.input_form` and `.pipe_io_contracts` are typed with them, so a node narrows on its `kind` and a slot's presence and multiplicity read as enums — but `pipelex_sdk` does not re-export the vocabulary, and importing it from here is the one supported path. +- **Input-form descriptors and pipe I/O contracts** come from `mthds` too, because they are the standard's artifacts and this SDK only carries them: `from mthds.protocol.input_form import InputForm, InputFormField, ListField, TextField, ...` and `from mthds.protocol.pipe_io_contracts import PipeIOContracts, PipeInputContract, PresenceMarker, IOMultiplicity, ...`. `PipelexValidationReport.input_form` and `.pipe_io_contracts` are typed with them, as are the three maps of `PipeIOValidReport` (with `mthds.protocol.output_form.OutputForm`), so a node narrows on its `kind` and a slot's presence and multiplicity read as enums — but `pipelex_sdk` does not re-export the vocabulary, and importing it from here is the one supported path. ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 2e43f90..7fa6525 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,13 +179,15 @@ The same three artifacts ride a completed run's results too — `RunResults.pipe **The one break.** A valid report whose contracts predate the presence/multiplicity reshape — an input carrying the boolean `optional` instead of `presence`, or missing `multiplicity` / `item_count` — no longer parses, where it used to ride through untyped. The hosted plane emits the reshaped contracts, so this bites only against runners older than that reshape, and there is no compatibility shim by design: an artifact that does not conform to the standard version this package pins is version drift, and saying so at the parse is the point. -## Crate routes (`resolve` / `codegen`) and the build closure selector +## Crate routes (`resolve` / `codegen` / `pipe_io`) and the build closure selector -`resolve` and `codegen` (`pipelex_sdk/crate_models.py` + the two client methods) are the second crate-family surface, mirroring the JS SDK's v0.12.0 routes: `POST /v1/resolve` emits the normalized library crate (the MTHDS Library Crate Format — typed as opaque transport, `dict[str, Any]`, because the crate schema is owned by the standard and a restatement here would be free to drift), and `POST /v1/codegen` projects that crate through the `kind` × `target` axes into stamped typed artifacts plus their `codegen.lock` (write both verbatim and the offline `pipelex codegen check` passes on the tree). Both follow the build routes' 200-verdict discipline, sharing `CrateInvalidReport`, and ride `_request_product`, so a no-verdict condition raises the typed `ApiResponseError`. +`resolve`, `codegen` and `pipe_io` (`pipelex_sdk/crate_models.py` + the three client methods) are the second crate-family surface, mirroring the JS SDK's routes: `POST /v1/resolve` emits the normalized library crate (the MTHDS Library Crate Format — typed as opaque transport, `dict[str, Any]`, because the crate schema is owned by the standard and a restatement here would be free to drift), `POST /v1/codegen` projects that crate through the `kind` × `target` axes into stamped typed artifacts plus their `codegen.lock` (write both verbatim and the offline `pipelex codegen check` passes on the tree), and `POST /v1/pipe-io` returns a method's three I/O artifacts without validating it. All three follow the build routes' 200-verdict discipline, sharing `CrateInvalidReport`, and ride `_request_product`, so a no-verdict condition raises the typed `ApiResponseError`. -Their closure selector is the strict three-way XOR — inline `files`, an address-form `method_ref` (server-resolved; the registry form keeps its `501`), or the hosted `method_id` pass-through — enforced at request construction (`CrateToolingRequest._exactly_one_selector`) as well as by the server. The shared envelope itself (`MthdsFileItem`, `CrateRequestBase`, `CrateInvalidReport`) lives in `crate_models.py` beside the routes that use it; it sat in a `build_models.py` module until `prepare_inputs` moved onto the input-form descriptor and the `/v1/build/inputs` wrapper was removed with it. +**`pipe_io` reads a method's inputs and outputs in one call, with no dry run.** The closure resolves through the same static core as `resolve`, one pipe is selected, and `PipeIOValidReport` carries that pipe's `pipe_io_contracts`, `input_form` and `output_form` — typed by import from `mthds.protocol`, under the same ruling and strictness as the validate report's members of those names (see "Typed by import" above) — beside the resolved qualified `pipe_ref`, the method's own `default_pipe_ref`, `pending_signatures` and `is_runnable`. `PipeIORequest` adds `pipe_ref` (sent as given), `all_pipes` (key the three maps by every pipe the closure loads, and never refuse for want of an entry pipe) and `include_files` (echo the closure's `.mthds` files on the valid arm, `None` otherwise). The server selects the pipe: the request's `pipe_ref`, else a fetched package manifest's `main_pipe`, else the closure's single `main_pipe` declaration; an unknown ref, or no ref and a chain that finds none or several, is a `422` the route raises as `ApiResponseError`. Two fields are easy to misread. `is_valid: true` means the closure passed static validation, never that it runs, since only `validate` dry-runs. And `default_pipe_ref` is the route's own chain, a stated `None` where that chain refuses to choose, which is not `validate`'s run default of the same name. `prepare_inputs` reads its signature from this route; see [`input-preparation.md`](./input-preparation.md). -**A `method_ref` closure gets a fetch-sized budget.** Resolving an address can make the server clone a repository before it answers, and the server-side clone timeout runs well past the 30s management budget on a cold cache — an abort there would report a healthy, still-cloning server as unreachable. So a `method_ref`-carrying `resolve` / `codegen` uses an internal 3-minute budget (`_METHOD_REF_FETCH_TIMEOUT_SECONDS`, threaded through `_request_product`'s `request_timeout` override); it is internal (no new caller-facing parameter) and inert behind the hosted gateway's own cap. Mirrors the JS SDK's fetch budget; the run routes and `validate` need none because they already ride the 20-min blocking ceiling. +Their closure selector is the strict three-way XOR — inline `files`, an address-form `method_ref` (server-resolved; the registry form keeps its `501`), or the hosted `method_id` pass-through — enforced at request construction (`CrateToolingRequest._exactly_one_selector`) as well as by the server. The JS SDK leaves that check to the server for its crate routes; the wire is the same either way. The shared envelope itself (`MthdsFileItem`, `CrateRequestBase`, `CrateInvalidReport`) lives in `crate_models.py` beside the routes that use it; it sat in a `build_models.py` module until `prepare_inputs` moved onto the input-form descriptor and the `/v1/build/inputs` wrapper was removed with it. + +**A `method_ref` closure gets a fetch-sized budget.** Resolving an address can make the server clone a repository before it answers, and the server-side clone timeout runs well past the 30s management budget on a cold cache — an abort there would report a healthy, still-cloning server as unreachable. So a `method_ref`-carrying `resolve` / `codegen` / `pipe_io` uses an internal 3-minute budget (`_METHOD_REF_FETCH_TIMEOUT_SECONDS`, threaded through `_request_product`'s `request_timeout` override); it is internal (no new caller-facing parameter) and inert behind the hosted gateway's own cap, which answers a request past 30 seconds with a `502` that a retry clears once the runner has cached the clone. Mirrors the JS SDK's fetch budget; the run routes and `validate` need none because they already ride the 20-min blocking ceiling. ## Codegen tree writer (`write_codegen_tree`) @@ -270,7 +272,7 @@ The download twin of input preparation, and the Python twin of `@pipelex/sdk`'s ## Out of scope -- The `/v1/build/*` helpers — `build_output`, `build_runner`, `concept`, `pipe_spec`. `build_inputs` shipped in 0.5.0 and was removed again once `prepare_inputs`, its only caller, moved onto `validate` + the input-form descriptor: this SDK no longer touches `/v1/build/*`, which the workspace is retiring (L-260829-848001 in the workspace ledger). +- The `/v1/build/*` helpers — `build_output`, `build_runner`, `concept`, `pipe_spec`. `build_inputs` shipped in 0.5.0 and was removed again once `prepare_inputs`, its only caller, moved onto the input-form descriptor (read from `validate` then, from `pipe_io` now): this SDK no longer touches `/v1/build/*`, which the workspace is retiring (L-260829-848001 in the workspace ledger). - 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. @@ -289,7 +291,7 @@ This SDK is a port of the TypeScript `@pipelex/sdk` (`PipelexApiClient`) and tra Each stays deferred rather than silently missing. Everything else — the protocol routes, the durable lifecycle, the whole product surface, and the errors — does have a Python equivalent. -**Methods** — everything outside the gap list above has a counterpart: 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 with paged listing and the two iterators, organizations, billing, Pipelex API keys, gateway key, onboarding, storage, run records with `get_run_detail`), the crate routes (`resolve`, `codegen`) with the verbatim `write_codegen_tree` and the offline `run_codegen_check`, the input-preparation surface (`upload_file` / `prepare_inputs`, taking all three method selectors and reading its signature from the input-form descriptor), and `health`. The method selectors (`method_ref` / `method_id`) match the JS v0.16.0 surface across the run and tooling methods, with one signature-shape divergence: JS `validate` takes a `ValidateMethodSelector` object in place of its first argument, while Python takes `method_ref=` / `method_id=` keyword parameters — same wire, same XOR, idiomatic per language. +**Methods** — everything outside the gap list above has a counterpart: 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 with paged listing and the two iterators, organizations, billing, Pipelex API keys, gateway key, onboarding, storage, run records with `get_run_detail`), the crate routes (`resolve`, `codegen`, `pipe_io`) with the verbatim `write_codegen_tree` and the offline `run_codegen_check`, the input-preparation surface (`upload_file` / `prepare_inputs`, taking all three method selectors and reading its signature from the input-form descriptor `pipe_io` returns for the pipe the server selects), and `health`. The method selectors (`method_ref` / `method_id`) match the JS v0.16.0 surface across the run and tooling methods, with one signature-shape divergence: JS `validate` takes a `ValidateMethodSelector` object in place of its first argument, while Python takes `method_ref=` / `method_id=` keyword parameters — same wire, same XOR, idiomatic per language. **The run-results surface** — `RunResults` tracks `@pipelex/sdk` 0.20.0 field for field, and is at parity with it. Every field is declared, with the two-path semantics [`run-results.md`](./run-results.md) states: `graph_spec` (lifted on the blocking path instead of written `None`), `graph_assembly_error`, the three I/O artifacts typed from `mthds.protocol` with `pipe_io_artifacts_error` beside them, the usage pair, `working_memory` (the hosted artifact relayed as its own key, the blocking one lifted off `pipe_output.working_memory`), and `pipe_output`; and the usage pair's fold, `summarize_usage`, with the summary types it returns. What stays different is idiomatic and deliberate: a key the hosted body did not carry is absent from `model_fields_set` where the JS reads `undefined`, and the page says how to read that. diff --git a/docs/input-preparation.md b/docs/input-preparation.md index a0a69c3..885ce70 100644 --- a/docs/input-preparation.md +++ b/docs/input-preparation.md @@ -2,7 +2,7 @@ > **Status: implemented** (`pipelex_sdk/upload.py`, `pipelex_sdk/prepare_inputs.py`). `upload_file` and `prepare_inputs` are the Python counterpart of `@pipelex/sdk`'s `uploadFile` / `prepareInputs`, built on the raw `upload()` wire call. The design of record for the current shape is shared with `@pipelex/sdk` and tracked as L-260829-300c50 in the workspace ledger; the two SDKs are kept semantically identical. > -> **Current scope.** `prepare_inputs` names the method three ways — inline `files`, a `method_ref` address, or a stored `method_id` — and reads the target pipe's signature from the standard's input-form descriptor. One piece is deliberately deferred and additive (it does not change this contract): the opt-in ingest of `http(s)` URLs into storage — for now an `http(s)` URL at a file position always passes through unchanged. +> **Current scope.** `prepare_inputs` names the method three ways — inline `files`, a `method_ref` address, or a stored `method_id` — and reads the target pipe's signature from the standard's input-form descriptor, which `POST /v1/pipe-io` returns for the pipe the server selects. One piece is deliberately deferred and additive (it does not change this contract): the opt-in ingest of `http(s)` URLs into storage — for now an `http(s)` URL at a file position always passes through unchanged. ## Why this exists @@ -66,29 +66,29 @@ Nothing is expanded client-side: `method_id` here is a **pass-through**, the sam #### Where the signature comes from -One `POST /v1/validate` per call, whatever the selector, asking for the **input-form descriptor**: +One `POST /v1/pipe-io` per call, whatever the selector, through the client's `pipe_io`: ```python -await client.validate(, True, …, views=[VALIDATION_VIEW_INPUT_FORM]) +await client.pipe_io(PipeIORequest(, pipe_ref=)) ``` -`allow_signatures=True` is deliberate. Preparation needs a pipe's *declared* inputs, and a bundle mid-authoring with an unresolved signature somewhere else must not be refused inputs for a pipe whose inputs are declared — whether the bundle runs is the run's verdict, not preparation's. An `is_valid: false` verdict still means the closure does not load, which is a preparation failure. +The route resolves the closure, selects the pipe (see below) and answers with that pipe's input-form descriptor, keyed by the qualified `pipe_ref` it resolved. It runs **no dry run**, so a call costs one load where `/v1/validate` dry-runs every pipe of the method. Preparation needs a pipe's *declared* inputs, which static validation settles: a pending signature elsewhere in the method does not refuse inputs to a pipe whose inputs are declared, and a method whose dry run would fail still prepares — whether the method runs is the run's verdict, not preparation's. An `is_valid: false` verdict still means the closure does not load, which is a preparation failure. -A `method_ref` makes the server clone a repository first; `validate` needs no special budget for it, because the route already defaults to the 20-minute execute ceiling. +A `method_ref` makes the server clone a repository first, so the call gets the crate routes' 3-minute fetch budget; any other selector gets the 30-second management budget. On the hosted API the gateway caps a request at 30 seconds whatever the client allows, so a cold clone can answer a `502` that a retry clears once the runner has cached it. -**A valid report that carries no descriptor is an error, never a silent "no uploads".** The descriptor rides `views: ["input_form"]` on pipelex-api >= 0.18.0; pointed at an older runner, `prepare_inputs` says so rather than returning inputs whose local paths would travel to the runner verbatim. +**`prepare_inputs` needs an API that serves `POST /v1/pipe-io`.** Against one that does not, the call raises `ApiResponseError` rather than falling back to another signature source. A valid answer that carries no descriptor for the pipe it names is an `InputPreparationError`, never a silent "no uploads" that would let local paths travel to the runner verbatim. + +**Known limit.** The route resolves the closure through the crate routes' static core, which refuses an address-based cross-package dependency that `/v1/validate` would load. Preparing a closure that carries one raises `InputPreparationError` on the route's invalid verdict. #### Pipe selection -`validate` has no pipe selector — its report describes every pipe, keyed by qualified `pipe_ref` — so the helper picks one, in this order: +The route selects the pipe, and the helper keeps no selection chain of its own: -1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`. A bare code, a non-string, or a ref the method does not declare, is an `InputPreparationError` listing the qualified refs — one step to fix. The helper never grows a searched `pipe_code`: search is a run-route affordance, and the descriptor is keyed by qualified refs. -2. **The report's typed resolved default** (`default_pipe_ref`), once the runner serves it: the ref a caller gets by omitting the selector, manifest-aware for a fetched package. Read when present; a server that predates it sends nothing. -3. **The bundle's declared `main_pipe`**, read defensively from the opaque `bundle_blueprint` and qualified by its `domain`. -4. **The single pipe**, when the method declares exactly one. -5. Otherwise an `InputPreparationError` naming the candidates and asking for `pipe_ref`. +1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`. A bare code or a non-string is refused with an `InputPreparationError` before any request — the runner would still resolve a bare code across domains today, and search is a run-route affordance this helper does not grow. A qualified ref the method does not declare is refused by the route. +2. **A fetched package manifest's `main_pipe`**, for a `method_ref`: a package that names its entry pipe in `METHODS.toml` alone needs no `pipe_ref`. +3. **The closure's own `main_pipe` declaration**, when exactly one domain declares one. -> **The manifest-only `main_pipe` gap.** A published package may name its entry pipe in `METHODS.toml` alone — `github.com/Pipelex/methods/documents` and `.../image_generation` do — and the validate report never carries a manifest. Until step 2's field ships, such a package needs an explicit `pipe_ref`; the error lists the candidates, so the fix is one line. +The chain stops at the first link that is present. A method that declares no entry pipe, or several, needs an explicit `pipe_ref` — there is no fall-back to "the single pipe", and nothing reads the bundle blueprint. **A refused selection is an `InputPreparationError`** carrying the server's reason (and the candidate refs, when the answer lists them apart from its reason), with the route's `ApiResponseError` as its `__cause__`. The route marks a refused selection by its `error_type`, the runner's entry-lookup error (`EntryPipeNotFoundError` for an unknown ref or no entry pipe, `EntryPipeAmbiguousError` for several); any other non-2xx — a `method_ref` that does not parse or fetch, an unknown `method_id`, a registry-form address, auth, a server fault — is left as the `ApiResponseError` it is. ## Compact or explicit-envelope inputs @@ -123,9 +123,9 @@ Earlier releases read the signature from the explicit inputs template (`POST /v1 - an **optional nested file field** was never rendered by the required-only template, so its position was invisible and the caller's local path travelled to the runner as a literal string; - a **text field merely named `url`** was read from disk and uploaded. -The descriptor states the resolved kind at every depth and includes optional fields, so both are gone. It is also the standard's own artifact, derived from authored facts rather than from a rendered shape, and `/v1/validate` resolves all three method selectors server-side — which is what made the uniform selector surface possible at no server cost. +The descriptor states the resolved kind at every depth and includes optional fields, so both are gone. It is also the standard's own artifact, derived from authored facts rather than from a rendered shape, and the route that serves it resolves all three method selectors server-side — which is what made the uniform selector surface possible at no server cost. -**If you actually wanted the template.** `prepare_inputs` no longer needs one, and this SDK's `build_inputs` wrapper went with it, but the template itself did not disappear — `mthds.protocol.inputs_template` projects one from the same descriptor, client-side: `render_inputs_template(descriptor=…, explicit=…, output_format=…)` for the JSON or TOML text, `project_inputs_template(descriptor=…, explicit=…)` for the dict. Ask `validate` for the `input_form` view, hand the pipe's descriptor to either, and the round-trip the removed route used to cost is gone too — which is what makes a template available for a method named only by `method_ref` or `method_id`. +**If you actually wanted the template.** `prepare_inputs` no longer needs one, and this SDK's `build_inputs` wrapper went with it, but the template itself did not disappear — `mthds.protocol.inputs_template` projects one from the same descriptor, client-side: `render_inputs_template(descriptor=…, explicit=…, output_format=…)` for the JSON or TOML text, `project_inputs_template(descriptor=…, explicit=…)` for the dict. Ask `pipe_io` for the pipe, hand its descriptor to either, and the round-trip the removed route used to cost is gone too — which is what makes a template available for a method named only by `method_ref` or `method_id`. **Known limit.** A class-backed concept (`structure = "SomeClass"`) whose reflection cannot map a field annotation collapses to `kind: "unknown"` in the descriptor, so a file field beneath one is invisible to this walk. That is a fidelity bug in the runtime's `build_input_form`, tracked separately; pass such a value as an already-uploaded storage URI until it is fixed. @@ -155,6 +155,8 @@ The contract distinguishes at least these semantic outcomes (exact typed excepti - **authentication / authorization failure** — `401` / `403`; - **transport failure** — network / server fault. +Reading the signature adds two more: the closure does not resolve, and the route refuses the pipe selection (see "Pipe selection" above). Both are `InputPreparationError`. + All preparation failures are raised **before any run is created**. ## Storage policy (inherited, Phase 1) diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index b72262a..bbbfd12 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -48,6 +48,9 @@ CodegenResponse, CodegenResponseAdapter, MthdsFileItem, + PipeIORequest, + PipeIOResponse, + PipeIOResponseAdapter, ResolveRequest, ResolveResponse, ResolveResponseAdapter, @@ -1235,13 +1238,14 @@ async def upload(self, upload_input: UploadInput) -> UploadedFile: body = upload_input.model_dump(mode="json", exclude_none=True) return UploadedFile.model_validate(await self._request_product("POST", "upload", body=body)) - # ── Crate extensions (Pipelex API — `/v1/resolve`, `/v1/codegen`) ───── + # ── Crate extensions (Pipelex API — `/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`) ───── # # The second crate-family surface, mirroring the JS SDK: `/v1/resolve` emits the # normalized library crate, `/v1/codegen` projects that crate into stamped typed - # artifacts plus their lock. Same envelope family and same 200-verdict discipline as - # the build routes, PLUS the hosted `method_id` selector under the tooling routes' - # strict three-way XOR (see `crate_models`). + # artifacts plus their lock, and `/v1/pipe-io` returns a method's three I/O artifacts + # with no dry run. Same envelope family and same 200-verdict discipline as the build + # routes, PLUS the hosted `method_id` selector under the tooling routes' strict + # three-way XOR (see `crate_models`). async def resolve(self, request: ResolveRequest) -> ResolveResponse: """Resolve a closure into its normalized library crate — `POST /v1/resolve`. @@ -1284,6 +1288,33 @@ async def codegen(self, request: CodegenRequest) -> CodegenResponse: raw = await self._request_product("POST", "codegen", body=body, request_timeout=_crate_request_timeout_seconds(request.method_ref)) return CodegenResponseAdapter.validate_python(raw) + async def pipe_io(self, request: PipeIORequest) -> PipeIOResponse: + """Read a method's I/O artifacts without validating it — `POST /v1/pipe-io`. + + The closure resolves through the same static core as `resolve`, one pipe is selected, + and the valid arm carries that pipe's `pipe_io_contracts`, `input_form` and + `output_form` (the standard's artifacts, typed from `mthds.protocol`), beside the + resolved qualified `pipe_ref`, the method's own `default_pipe_ref`, its + `pending_signatures` and `is_runnable`. `all_pipes=True` keys the three maps by every + pipe the closure loads instead; `include_files=True` echoes the closure's `.mthds` + files. It runs NO dry-run sweep, so it costs one load where `validate` dry-runs every + pipe, and a valid verdict never says the method runs. + + Same three-form closure selector as `resolve`, enforced at request construction and by + the server alike. The pipe is selected by the request's `pipe_ref`, else a fetched + package manifest's `main_pipe`, else the closure's single `main_pipe` declaration. + + Returns a 200 verdict: branch on `is_valid` before reading the arm. A no-verdict + condition raises `ApiResponseError`: a refused selection (an unknown ref, or no + `pipe_ref` and a chain that finds no entry pipe or several, without `all_pipes`) and a + malformed request are `422`s; the `method_ref` fetch failures and the `method_id` + resolution failures are those of `resolve`; an artifact the server cannot derive is a + `500`. + """ + body = request.model_dump(mode="json", exclude_none=True) + raw = await self._request_product("POST", "pipe-io", body=body, request_timeout=_crate_request_timeout_seconds(request.method_ref)) + return PipeIOResponseAdapter.validate_python(raw) + async def upload_file( self, source: UploadSource, @@ -1317,11 +1348,13 @@ async def prepare_inputs( The method is named exactly one of three ways — inline `files`, a `method_ref` address (runner-resolved) or a stored `method_id` (platform-resolved) — all server-resolved, with nothing expanded client-side. An empty selector is treated as absent. The - signature comes from one `POST /v1/validate` asking for the `input_form` view, so the - walk is guided by each input's DECLARED kind rather than by the shape of its value. + signature comes from one `POST /v1/pipe-io` (see `pipe_io`), which selects the pipe + and returns its input-form descriptor, so the walk is guided by each input's DECLARED + kind rather than by the shape of its value. - `pipe_ref` is qualified-only (`domain.pipe_code`); omit it to default. See - `docs/input-preparation.md`. + `pipe_ref` is qualified-only (`domain.pipe_code`); omit it and the server selects the + method's entry pipe. A refused selection raises `InputPreparationError` with the + server's reason. See `docs/input-preparation.md`. """ return await _prepare_inputs_impl( self, @@ -1536,7 +1569,7 @@ def _assert_method_ref_pairs_with_nothing(*, mthds_contents: list[str] | None, m def _crate_request_timeout_seconds(method_ref: str | None) -> float: - """The request budget for a call carrying a crate closure (`/v1/resolve`, `/v1/codegen`): + """The request budget for a call carrying a crate closure (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`): the management default, unless the closure is a `method_ref` the server may have to fetch first — see `_METHOD_REF_FETCH_TIMEOUT_SECONDS`. """ diff --git a/pipelex_sdk/crate_models.py b/pipelex_sdk/crate_models.py index 8712fe6..40dd9f4 100644 --- a/pipelex_sdk/crate_models.py +++ b/pipelex_sdk/crate_models.py @@ -1,5 +1,5 @@ -"""Wire models for the crate routes — `POST /v1/resolve` and `POST /v1/codegen` — and the -shared crate envelope they are built on. +"""Wire models for the crate routes — `POST /v1/resolve`, `POST /v1/codegen` and +`POST /v1/pipe-io` — and the shared crate envelope they are built on. The envelope lives here because these are the routes that still use it. `MthdsFileItem`, `CrateRequestBase` and `CrateInvalidReport` used to sit in a `build_models` module beside the @@ -8,11 +8,13 @@ L-260829-848001). Nothing about the envelope changed in the move. `/v1/resolve` emits the normalized library crate, `/v1/codegen` projects that crate into stamped -typed artifacts plus their lock. Both are Pipelex API extensions (NOT MTHDS Protocol routes) over -the standard-owned artifact, so their wire fields stay brand-neutral. A produced verdict is a -`200` discriminated on `is_valid`, with `CrateInvalidReport` as the shared invalid arm; a -no-verdict condition (a malformed selector, a selector-resolution failure, auth, a server fault) -raises `ApiResponseError`. +typed artifacts plus their lock, and `/v1/pipe-io` returns a method's three I/O artifacts — pipe +I/O contracts, input form, output form — with no dry run. All three are Pipelex API extensions +(NOT MTHDS Protocol routes) over standard-owned artifacts, so their wire fields stay +brand-neutral. A produced verdict is a `200` discriminated on `is_valid`, with +`CrateInvalidReport` as the shared invalid arm; a no-verdict condition (a malformed selector, a +selector-resolution failure, a refused pipe selection, auth, a server fault) raises +`ApiResponseError`. The closure arrives in exactly one of three forms — the tooling routes' strict three-way XOR: inline `files`, an address-form `method_ref` (server-resolved, pipelex-api >= 0.21.0; @@ -26,6 +28,9 @@ from typing import Annotated, Any, Literal, Self, TypeAlias +from mthds.protocol.input_form import InputForm +from mthds.protocol.output_form import OutputForm +from mthds.protocol.pipe_io_contracts import PipeIOContracts from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator from pipelex_sdk.validation_models import ValidationErrorItem @@ -95,7 +100,7 @@ class CrateInvalidReport(BaseModel): class CrateToolingRequest(CrateRequestBase): """The crate envelope plus the hosted tooling selector — the request base - `/v1/resolve` and `/v1/codegen` share. + `/v1/resolve`, `/v1/codegen` and `/v1/pipe-io` share. `method_id` is a stored method's catalog id (`mt_…`), a **pass-through to the hosted API**: the platform resolves it against the org's catalog and injects the stored @@ -235,3 +240,68 @@ class CodegenValidReport(BaseModel): # The single parse path for a 200 `/codegen` body — same regime as `ResolveResponseAdapter`. CodegenResponseAdapter: TypeAdapter[CodegenResponse] = TypeAdapter(CodegenResponse) # pylint: disable=invalid-name + + +class PipeIORequest(CrateToolingRequest): + """Request for `POST /v1/pipe-io` — the crate envelope plus a pipe selector and two opt-ins, + with the hosted `method_id` selector (exactly one of `files` / `method_ref` / `method_id`). + + `pipe_ref` names the pipe to describe by its qualified ref (`domain.pipe_code`) and is sent + as given. Omitted, the server's selection chain decides: a fetched package manifest's + `main_pipe`, else the closure's single `main_pipe` declaration — and a chain that finds none, + or several, is a request-shape `422` unless `all_pipes` is set. The server resolves a bare + ref across domains today; `prepare_inputs` refuses one before sending it. + + `all_pipes` describes every pipe the closure loads instead of the selected one, and never + refuses for want of an entry pipe. `include_files` echoes the resolved closure's `.mthds` + files on the valid arm. + """ + + pipe_ref: str | None = None + all_pipes: bool = False + include_files: bool = False + + +class PipeIOValidReport(BaseModel): + """The `/v1/pipe-io` valid arm — a method's three I/O artifacts, with the selection and the + runnability facts beside them. + + The three artifact maps are the standard's, typed by import from `mthds.protocol` exactly as + `PipelexValidationReport` types its same-named members, and they share one key set: the + resolved `pipe_ref` alone by default, every pipe the closure loads under `all_pipes`. For a + closure `/v1/validate` also accepts, each map equals validate's same-named field restricted + to the same keys. `is_valid: true` means the closure parsed, loaded and passed static + validation; no dry run ran, so it never says the method runs. + + `default_pipe_ref` is the method's own entry pipe — the selection chain without the + request's `pipe_ref` — and a stated `null` when that chain finds none or several. It is NOT + `/v1/validate`'s field of the same name, which is the run default. + """ + + model_config = ConfigDict(extra="allow") + + is_valid: Literal[True] + #: The qualified ref the selection resolved, read off the resolved pipe and never echoed from + #: the request. `None` only under `all_pipes` when nothing resolves. + pipe_ref: str | None + pipe_io_contracts: PipeIOContracts + input_form: InputForm + output_form: OutputForm + default_pipe_ref: str | None + #: The qualified refs of every pipe of the closure still declared as a signature. + pending_signatures: list[str] + #: `not pending_signatures`, exactly as on `/v1/validate`. No dry run backs it. + is_runnable: bool + #: The resolved closure's `.mthds` files in the request's `files` shape; `None` unless the + #: request set `include_files`. + files: list[MthdsFileItem] | None = None + + +# Named after the route and the JS twin's `PipeIOResponse`; pylint's alias pattern rejects the `IO` run. +PipeIOResponse: TypeAlias = Annotated[ # pylint: disable=invalid-name + PipeIOValidReport | CrateInvalidReport, + Field(discriminator="is_valid"), +] + +# The single parse path for a 200 `/pipe-io` body — same regime as `ResolveResponseAdapter`. +PipeIOResponseAdapter: TypeAdapter[PipeIOResponse] = TypeAdapter(PipeIOResponse) # pylint: disable=invalid-name diff --git a/pipelex_sdk/prepare_inputs.py b/pipelex_sdk/prepare_inputs.py index 484e0ee..7b07c05 100644 --- a/pipelex_sdk/prepare_inputs.py +++ b/pipelex_sdk/prepare_inputs.py @@ -4,14 +4,14 @@ returns rewritten inputs (canonical content carrying `pipelex-storage://` in `url`) plus one upload record per prepared asset. Python counterpart of `pipelex-sdk-js`'s `prepareInputs`. -The signature comes from ONE `POST /v1/validate` asking for `views: ["input_form"]`, and the -walk is discriminated on each descriptor node's declared `kind` — never on the shape of a -value. That is the whole point: the previous source, the explicit inputs template, marked a -file position by rendering a `{"url": …}` dict, which is a side effect of a field being NAMED -`url` rather than of its concept being an Image or a Document. Two positions were misread as a -result — an OPTIONAL nested file field, which the required-only template never rendered, was -left un-uploaded and its local path travelled to the runner as a literal string; and a text -field merely named `url` was read from disk and uploaded. The descriptor states the resolved +The signature comes from ONE `POST /v1/pipe-io`, which selects the pipe server-side and returns +its input-form descriptor with no dry run, and the walk is discriminated on each descriptor +node's declared `kind` — never on the shape of a value. That is the whole point: the previous +source, the explicit inputs template, marked a file position by rendering a `{"url": …}` dict, +which is a side effect of a field being NAMED `url` rather than of its concept being an Image or +a Document. Two positions were misread as a result — an OPTIONAL nested file field, which the +required-only template never rendered, was left un-uploaded and its local path travelled to the +runner as a literal string; and a text field merely named `url` was read from disk and uploaded. The descriptor states the resolved kind at every depth and includes optional fields, so both are gone. See `docs/input-preparation.md`. The design of record is shared with `@pipelex/sdk` and @@ -33,7 +33,6 @@ DocumentItem, EnumItem, ImageItem, - InputForm, InputFormItem, ListItem, NumberItem, @@ -44,17 +43,30 @@ ) from pydantic import BaseModel -from pipelex_sdk.errors import InputPreparationError +from pipelex_sdk.crate_models import CrateInvalidReport, PipeIORequest, PipeIOValidReport +from pipelex_sdk.errors import ApiResponseError, InputPreparationError from pipelex_sdk.upload import UploadRecord, UploadSource, upload_file -from pipelex_sdk.validation_models import VALIDATION_VIEW_INPUT_FORM, PipelexInvalidReport, PipelexValidationReport, PipelexValidationResult if TYPE_CHECKING: - from pipelex_sdk.crate_models import MthdsFileItem + from pipelex_sdk.crate_models import MthdsFileItem, PipeIOResponse from pipelex_sdk.product_models import UploadedFile, UploadInput PIPELEX_STORAGE_SCHEME = "pipelex-storage://" _HTTP_URL_RE = re.compile(r"^https?://", re.IGNORECASE) +# How `/v1/pipe-io` says it refused the pipe selection, which `_fetch_signature` turns into an +# `InputPreparationError`: a `422` whose `error_type` is one of the engine's entry-lookup errors. +# `EntryPipeNotFoundError` is an unknown `pipe_ref`, or no `pipe_ref` and a method declaring no +# entry pipe; `EntryPipeAmbiguousError` is a code matching pipes in several domains, or several +# `main_pipe` declarations. Every other `422` — a malformed body, a `method_ref` that does not +# parse or fetch, a stored method with no source — is not a selection and stays the +# `ApiResponseError` it is. The names are the runner's exception classes; they live here alone, +# so a rename upstream is a one-line edit. +_HTTP_UNPROCESSABLE_ENTITY = 422 +_PIPE_SELECTION_ERROR_TYPES: frozenset[str] = frozenset({"EntryPipeNotFoundError", "EntryPipeAmbiguousError"}) +# The problem-document member a selection refusal may carry its candidate qualified refs in. +_CANDIDATES_MEMBER = "candidates" + class PreparedInputs(BaseModel): """The result of `prepare_inputs`: rewritten inputs (copy-on-write) plus upload records. @@ -69,24 +81,14 @@ class PreparedInputs(BaseModel): class _PrepareClient(Protocol): - """The client surface `prepare_inputs` needs: raw `upload` plus `validate` as the - signature source. Typed as `PipelexAPIClient.validate`'s own signature so the client - satisfies it structurally. + """The client surface `prepare_inputs` needs: raw `upload` plus `pipe_io` as the + signature source. Typed as `PipelexAPIClient`'s own signatures so the client satisfies it + structurally. """ async def upload(self, upload_input: UploadInput) -> UploadedFile: ... - async def validate( - self, - mthds_contents: list[str] | None = None, - allow_signatures: bool = False, - mthds_sources: list[str] | None = None, - render: list[str] | None = None, - views: list[str] | None = None, - *, - method_ref: str | None = None, - method_id: str | None = None, - ) -> PipelexValidationResult: ... + async def pipe_io(self, request: PipeIORequest) -> PipeIOResponse: ... class _PrepareContext: @@ -99,36 +101,24 @@ def __init__(self, client: _PrepareClient) -> None: self.dedup: dict[UploadSource, str] = {} -def _non_empty_string(value: object) -> str | None: - """A trimmed non-empty string, or `None` — the "empty is absent" rule. +def _caller_selector(value: object, *, argument: str) -> str | None: + """A caller-supplied selector, trimmed — `None` when absent, refused when not a string. - Lenient on purpose, because what it reads is OPAQUE server payload — `bundle_blueprint`, - whose schema is the runtime's, not ours — where a shape that does not match is genuinely - an absent value to fall through on. A CALLER-supplied selector goes through - `_caller_selector` instead, which refuses a non-string rather than reading it as absent. + The "empty is absent" rule, plus a boundary check. Coercing a non-string to `None` here + would read `method_ref=123` as an absent selector and let it fall through to another one — + defeating the exactly-one check this whole surface rests on — and would let a non-string + `pipe_ref` silently take the default pipe instead of the one the caller named. Both are + caller mistakes, and a caller mistake owes an `InputPreparationError` raised before any + request. Deliberately local rather than reusing `client.py`'s `_normalized_selector`: that helper is private to the client boundary and raises `PipelineRequestError`, where every failure of this module owes an `InputPreparationError`. """ - if not isinstance(value, str): + if value is None: return None - trimmed = value.strip() - return trimmed or None - - -def _caller_selector(value: object, *, argument: str) -> str | None: - """A caller-supplied selector, trimmed — `None` when absent, refused when not a string. - - The "empty is absent" rule of `_non_empty_string`, plus the boundary check that helper - must not make. Coercing a non-string to `None` here would read `method_ref=123` as an - absent selector and let it fall through to another one — defeating the exactly-one check - this whole surface rests on — and would let a non-string `pipe_ref` silently take the - default pipe instead of the one the caller named. Both are caller mistakes, and a caller - mistake owes an `InputPreparationError` raised before any request. - """ - if value is None or isinstance(value, str): - return _non_empty_string(value) + if isinstance(value, str): + return value.strip() or None msg = f"Cannot prepare inputs: `{argument}` must be a string, got {type(value).__name__}." raise InputPreparationError(msg) @@ -285,8 +275,8 @@ def _resolve_selector( options' rule and the `CrateRequestBase` normalizers, so an empty selector may sit beside a real one without tripping the XOR. A non-string `method_ref` / `method_id` is NOT absent but refused, so a mistyped selector cannot slip past the XOR as a silent `None`. The check - lives here because this module is what composes the `validate` call, and it runs BEFORE - any request. + lives here, rather than in `PipeIORequest`'s own validator, so that it raises the + `InputPreparationError` this module owes, and it runs BEFORE any request. """ selected_files = files or None selected_method_ref = _caller_selector(method_ref, argument="method_ref") @@ -316,102 +306,78 @@ def _resolve_selector( return selected_files, selected_method_ref, selected_method_id -async def _fetch_signature( - client: _PrepareClient, - *, - files: list[MthdsFileItem] | None, - method_ref: str | None, - method_id: str | None, -) -> PipelexValidationReport: - """Ask `validate` for the signature, whatever the selector, and hand back the valid report. +def _checked_pipe_ref(pipe_ref: object) -> str | None: + """The caller's `pipe_ref`, normalized — `None` when absent, refused when bare or not a string. - `allow_signatures=True` on purpose: preparation needs a pipe's DECLARED inputs, and a - bundle mid-authoring with an unresolved signature elsewhere must not be refused inputs for - a pipe whose inputs are declared — whether the bundle runs is the run's verdict, not - preparation's. An `is_valid: false` arm still means the closure does not load, which IS a - preparation failure. - - No timeout override for a `method_ref`: `validate` already rides the 20-minute blocking - ceiling, and the internal 3-minute fetch budget exists to RAISE the ~30s poll-ceiling - routes, not to lower this one. + Qualified-only is this helper's contract: the descriptor is keyed by qualified refs, and a + searched `pipe_code` is a run-route affordance preparation does not grow. The route would + still resolve a bare code across domains today — the pipe-selector rule that refuses one + server-side has not reached the runner's shared selection yet — so the refusal stays here, + raised before any request. """ - views = [VALIDATION_VIEW_INPUT_FORM] - result: PipelexValidationResult - if files is not None: - contents = [file_item.content for file_item in files] - # `validate_files`' rule: label every content once any file names a source, so the - # server never sees a length-mismatched `mthds_sources` array. - sources: list[str] | None - if any(file_item.source is not None for file_item in files): - sources = [file_item.source or f"inline://file-{index + 1}.mthds" for index, file_item in enumerate(files)] - else: - sources = None - result = await client.validate(contents, True, sources, None, views) - else: - result = await client.validate(None, True, None, None, views, method_ref=method_ref, method_id=method_id) - - if isinstance(result, PipelexInvalidReport): - first = result.validation_errors[0].message if result.validation_errors else result.message - msg = f"Cannot prepare inputs: the method signature did not resolve — {first}" + requested = _caller_selector(pipe_ref, argument="pipe_ref") + if requested is not None and "." not in requested: + msg = f'Cannot prepare inputs: `pipe_ref` must be qualified (`domain.pipe_code`), got the bare "{requested}".' raise InputPreparationError(msg) - return result + return requested -def _blueprint_main_pipe_ref(blueprint: dict[str, Any]) -> str | None: - """The bundle blueprint's declared `main_pipe`, qualified by its `domain` when authored bare. +def _is_pipe_selection_refusal(exc: ApiResponseError) -> bool: + """Whether a `/v1/pipe-io` error is the route refusing the pipe selection, and nothing else.""" + return exc.status == _HTTP_UNPROCESSABLE_ENTITY and exc.error_type in _PIPE_SELECTION_ERROR_TYPES - Every read is defensive: `bundle_blueprint` is carried opaquely by this SDK on purpose — - its schema is the runtime's, not ours — so a shape that does not match falls through - rather than raising. - """ - main_pipe = _non_empty_string(blueprint.get("main_pipe")) - if main_pipe is None: - return None - if "." in main_pipe: - return main_pipe - domain = _non_empty_string(blueprint.get("domain")) - return f"{domain}.{main_pipe}" if domain is not None else None +def _selection_refusal_reason(exc: ApiResponseError) -> str: + """The server's reason for a refused selection, with its candidates when the body lists them. -def _select_pipe_ref(report: PipelexValidationReport, input_form: InputForm, requested: str | None) -> str: - """Pick the pipe whose descriptor guides the walk. + The reason is the problem's `detail`. A candidate list the body carries as its own member is + appended unless the detail already names every candidate, as the engine's ambiguity message + does, so the refs are never repeated. Read defensively: a member that is not a list of + strings is ignored rather than trusted. + """ + reason = exc.server_message or exc.title or exc.response_body or exc.status_text + raw_candidates: object = exc.problem.get(_CANDIDATES_MEMBER) if exc.problem is not None else None + if not isinstance(raw_candidates, list): + return reason + candidates = [candidate for candidate in cast("list[object]", raw_candidates) if isinstance(candidate, str)] + if not candidates or all(candidate in reason for candidate in candidates): + return reason + return f"{reason} Candidates: {', '.join(candidates)}." + + +async def _fetch_signature(client: _PrepareClient, *, request: PipeIORequest) -> PipeIOValidReport: + """Ask `pipe_io` for the selected pipe's signature and hand back the valid report. + + The route selects the pipe — the request's `pipe_ref`, else a fetched package manifest's + `main_pipe`, else the closure's single `main_pipe` declaration — so this module keeps no + selection chain of its own, and a package that names its entry pipe in its manifest alone + is selected like any other. + + The route runs no dry run. Preparation needs a pipe's DECLARED inputs, which static + validation settles, so a pending signature elsewhere in the method does not refuse inputs + to a pipe whose inputs are declared — whether the method runs is the run's verdict, not + preparation's. An `is_valid: false` arm still means the closure does not load, which IS a + preparation failure. - `validate` has no pipe selector — its report describes every pipe, keyed by qualified - `pipe_ref` — so the choice is made here, in the order `docs/input-preparation.md` - documents: an explicit qualified `pipe_ref`, then the report's typed resolved default, - then the bundle's declared `main_pipe`, then the single pipe, else an error naming the - candidates. + A refused selection — a `422` whose `error_type` is an entry-lookup error (see + `_PIPE_SELECTION_ERROR_TYPES`) — becomes an `InputPreparationError` carrying the server's + reason and its candidates, with the `ApiResponseError` kept as its `__cause__` for a caller + who needs the whole problem document. Every other non-2xx propagates as the + `ApiResponseError` it is. """ - refs = list(input_form) - candidates = ", ".join(refs) if refs else "(none — the closure declares no pipes)" - - if requested is not None: - if "." not in requested: - msg = ( - "Cannot prepare inputs: `pipe_ref` must be qualified (`domain.pipe_code`), got the bare " - f'"{requested}". The method declares: {candidates}.' - ) - raise InputPreparationError(msg) - if requested not in input_form: - msg = f'Cannot prepare inputs: the method declares no pipe "{requested}". It declares: {candidates}.' - raise InputPreparationError(msg) - return requested - - # The typed resolved default, when the runner serves it (manifest-aware for a `method_ref` - # package, which is why it outranks the blueprint read below). - typed_default = _non_empty_string(report.default_pipe_ref) - if typed_default is not None and typed_default in input_form: - return typed_default - - blueprint_default = _blueprint_main_pipe_ref(report.bundle_blueprint) - if blueprint_default is not None and blueprint_default in input_form: - return blueprint_default - - if len(refs) == 1: - return refs[0] - - msg = f"Cannot prepare inputs: the method declares no single default pipe, so `pipe_ref` is required. It declares: {candidates}." - raise InputPreparationError(msg) + try: + response = await client.pipe_io(request) + except ApiResponseError as exc: + if not _is_pipe_selection_refusal(exc): + raise + msg = f"Cannot prepare inputs: the pipe could not be selected — {_selection_refusal_reason(exc)}" + raise InputPreparationError(msg) from exc + + if isinstance(response, CrateInvalidReport): + first = response.validation_errors[0].message if response.validation_errors else response.message + msg = f"Cannot prepare inputs: the method signature did not resolve — {first}" + raise InputPreparationError(msg) + return response async def prepare_inputs( @@ -427,16 +393,17 @@ async def prepare_inputs( file-bearing positions and return copy-on-write rewritten inputs plus upload records. Args: - client: The client supplying `upload` and `validate`. + client: The client supplying `upload` and `pipe_io`. files: The method closure inline. Exactly one of `files` / `method_ref` / `method_id`. method_ref: A published method's address — `github.com//[/][@]` — resolved by the runner. method_id: A stored method's hosted catalog id (`mt_…`), resolved by the platform. A pure pass-through: nothing is expanded client-side. - pipe_ref: The target pipe as a QUALIFIED `domain.pipe_code`. Omit it to default — - see "Pipe selection" in `docs/input-preparation.md`. A bare `pipe_code` is - refused: the descriptor is keyed by qualified refs, and search is a run-route - affordance this helper deliberately does not grow. + pipe_ref: The target pipe as a QUALIFIED `domain.pipe_code`. Omit it and the route + selects the method's entry pipe — see "Pipe selection" in + `docs/input-preparation.md`. A bare `pipe_code` is refused before any request: + the descriptor is keyed by qualified refs, and search is a run-route affordance + this helper deliberately does not grow. inputs: The caller's inputs (variable name → value), compact or explicit-envelope per input. @@ -446,35 +413,35 @@ async def prepare_inputs( per uploaded asset. Raises: - InputPreparationError: No selector or several; a selector that is not a string; the - closure did not resolve; the report carries no descriptor; the pipe could not be - selected; or a value at a file position is unusable. HTTP(S) URLs and existing - `pipelex-storage://` URIs pass through unchanged, and every failure is raised - BEFORE any run is created. - ApiResponseError: A no-verdict condition from `/v1/validate` — a malformed - selector, an unknown or foreign-org `method_id` (`404`), a stored method with no - source, a fetch failure at the address. `validate` is 200-diagnostic, so only a - failure to produce any verdict arrives here, as the typed error every route raises. + InputPreparationError: No selector or several; a selector or `pipe_ref` that is not a + string; a bare `pipe_ref`; the closure did not resolve; the route refused the pipe + selection — an unknown `pipe_ref`, or no `pipe_ref` and a method declaring no + single entry pipe — carrying the server's reason, with the `ApiResponseError` as + its `__cause__`; or a value at a file position is unusable. HTTP(S) URLs and + existing `pipelex-storage://` URIs pass through unchanged, and every failure is + raised BEFORE any run is created. + ApiResponseError: Any other no-verdict condition from `/v1/pipe-io` — an unknown or + foreign-org `method_id` or no package at a `method_ref` address (`404`), a + `method_ref` that does not parse or fetch or a stored method with no source + (`422`), a registry-form `method_ref` (`501`), auth, a server fault, or an API that + does not serve the route at all. """ selected_files, selected_method_ref, selected_method_id = _resolve_selector(files=files, method_ref=method_ref, method_id=method_id) - # Normalized here rather than at its use below, so a mistyped `pipe_ref` is refused on the - # same pre-request boundary as a mistyped selector — before the `validate` round-trip. - requested_pipe_ref = _caller_selector(pipe_ref, argument="pipe_ref") - report = await _fetch_signature(client, files=selected_files, method_ref=selected_method_ref, method_id=selected_method_id) - - input_form = report.input_form - if input_form is None: - # Never a silent degrade to "no uploads": without the descriptor there is no - # signature to prepare against. - msg = ( - "Cannot prepare inputs: the validate report carries no `input_form` descriptor — the signature " - 'preparation reads. The descriptor rides `views: ["input_form"]` on pipelex-api >= 0.18.0; ' - "point the client at a runner that serves it." - ) + # Checked here rather than after the round-trip, so a mistyped or bare `pipe_ref` is refused + # on the same pre-request boundary as a mistyped selector. + requested_pipe_ref = _checked_pipe_ref(pipe_ref) + request = PipeIORequest(files=selected_files, method_ref=selected_method_ref, method_id=selected_method_id, pipe_ref=requested_pipe_ref) + report = await _fetch_signature(client, request=request) + + selected_pipe_ref = report.pipe_ref + descriptor = report.input_form.get(selected_pipe_ref) if selected_pipe_ref is not None else None + if descriptor is None: + # The route promises the selected pipe's descriptor on every single-pipe valid answer. + # Never a silent degrade to "no uploads": without it there is no signature to prepare + # against, and the caller's local paths would travel to the runner verbatim. + msg = f"Cannot prepare inputs: the pipe-io answer carries no input-form descriptor for the selected pipe ({selected_pipe_ref!r})." raise InputPreparationError(msg) - - selected_pipe_ref = _select_pipe_ref(report, input_form, requested_pipe_ref) - declared = {field.name: field for field in input_form[selected_pipe_ref].fields} + declared = {field.name: field for field in descriptor.fields} ctx = _PrepareContext(client) rewritten = dict(inputs) diff --git a/tests/e2e/test_pipe_io_e2e.py b/tests/e2e/test_pipe_io_e2e.py new file mode 100644 index 0000000..b260f4c --- /dev/null +++ b/tests/e2e/test_pipe_io_e2e.py @@ -0,0 +1,222 @@ +"""`pipe_io` and `prepare_inputs` on `POST /v1/pipe-io`, exercised against a LIVE API (no mocks). + +Run it with `make e2e-test` against any API that serves the route — a local `pipelex-api` or the +hosted platform: + + PIPELEX_E2E_BASE_URL=http://127.0.0.1:8082 make e2e-test + PIPELEX_E2E_BASE_URL=https://api-dev.pipelex.com PIPELEX_API_KEY=plx_sk_… make e2e-test + +The whole module skips when `PIPELEX_E2E_BASE_URL` is unset, so `make agent-test`, which does not +collect this directory at all, never reaches it. The inline `files` and `method_ref` cases need only +the runner. The hosted `method_id` case needs the platform's catalog, and skips unless +`PIPELEX_API_KEY` is set: a bare runner answers without a key, and has no catalog to resolve an id +against. No case uploads a file, since a bare runner serves no `/v1/upload`; the upload leg rides +`test_artifacts_e2e.py` on the platform. + +What the unit suite cannot prove: that the answer the models parse, the selection the route makes, +and the refusal `prepare_inputs` maps are the ones a real server produces. +""" + +from __future__ import annotations + +import asyncio +import os +import time +from typing import TYPE_CHECKING + +import pytest +from mthds.protocol.input_form import DocumentField, ObjectField + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.crate_models import CrateInvalidReport, MthdsFileItem, PipeIORequest, PipeIOValidReport +from pipelex_sdk.errors import InputPreparationError +from pipelex_sdk.product_models import MethodWriteInput + +if TYPE_CHECKING: + from pipelex_sdk.crate_models import PipeIOResponse + from pipelex_sdk.prepare_inputs import PreparedInputs + +_BASE_URL = os.environ.get("PIPELEX_E2E_BASE_URL", "") +_API_KEY = os.environ.get("PIPELEX_API_KEY", "") + +pytestmark = pytest.mark.skipif(not _BASE_URL, reason="live leg: set PIPELEX_E2E_BASE_URL to run it") + +#: A published package whose entry pipe is named in its `METHODS.toml` alone. +_METHOD_REF = "github.com/Pipelex/methods/documents@v0.1.0" +_DOCUMENT_URL = "https://example.com/brief.pdf" + +#: One domain declaring its entry pipe: a Document, a Text, and a structured input with an optional +#: nested Image — no inference, and every declared input read by the template. +_ENTRY_BUNDLE = '''domain = "smoke_pipe_io" +main_pipe = "echo" + +[concept.Dossier] +description = "A dossier" + +[concept.Dossier.structure] +title = { type = "text", description = "Title", required = true } +cover = { type = "concept", concept_ref = "native.Image", description = "Cover" } + +[pipe.echo] +type = "PipeCompose" +description = "Echo the note beside a document and a dossier" +inputs = { doc = "Document", note = "Text", dossier = "Dossier" } +output = "Text" +template = """ +$note + +@doc + +@dossier +""" +''' + +#: Two pipes and no `main_pipe`: the route cannot select an entry pipe without being told one. +_NO_ENTRY_BUNDLE = """domain = "smoke_pipe_io_open" + +[pipe.first] +type = "PipeCompose" +description = "Echo a note" +inputs = { note = "Text" } +output = "Text" +template = "$note" + +[pipe.second] +type = "PipeCompose" +description = "Echo a topic" +inputs = { topic = "Text" } +output = "Text" +template = "$topic" +""" + +_ENTRY_FILES = [MthdsFileItem(content=_ENTRY_BUNDLE, source="smoke_pipe_io.mthds")] +_NO_ENTRY_FILES = [MthdsFileItem(content=_NO_ENTRY_BUNDLE, source="smoke_pipe_io_open.mthds")] + + +def _client() -> PipelexAPIClient: + return PipelexAPIClient(api_key=_API_KEY or None, base_url=_BASE_URL) + + +def _pipe_io(request: PipeIORequest) -> PipeIOResponse: + async def _call() -> PipeIOResponse: + async with _client() as client: + return await client.pipe_io(request) + + return asyncio.run(_call()) + + +class TestPipeIOLive: + # ── The route ──────────────────────────────────────────────────── + + def test_selects_the_declared_entry_pipe_and_types_its_artifacts(self) -> None: + report = _pipe_io(PipeIORequest(files=_ENTRY_FILES)) + + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref == "smoke_pipe_io.echo" + assert report.default_pipe_ref == "smoke_pipe_io.echo" + assert report.is_runnable is True + assert report.pending_signatures == [] + assert report.files is None + assert set(report.pipe_io_contracts) == set(report.input_form) == set(report.output_form) == {"smoke_pipe_io.echo"} + fields = report.input_form["smoke_pipe_io.echo"].fields + assert [field.name for field in fields] == ["doc", "note", "dossier"] + assert isinstance(fields[0], DocumentField) + dossier = fields[2] + assert isinstance(dossier, ObjectField) + assert [(field.name, field.required) for field in dossier.fields] == [("title", True), ("cover", False)] + + def test_describes_a_method_with_no_entry_pipe_under_all_pipes_and_echoes_its_files(self) -> None: + report = _pipe_io(PipeIORequest(files=_NO_ENTRY_FILES, all_pipes=True, include_files=True)) + + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref is None + assert report.default_pipe_ref is None + assert set(report.input_form) == {"smoke_pipe_io_open.first", "smoke_pipe_io_open.second"} + assert report.files == _NO_ENTRY_FILES + + def test_an_invalid_closure_is_the_crate_verdict(self) -> None: + broken = [MthdsFileItem(content=_ENTRY_BUNDLE.replace('type = "PipeCompose"', 'type = "PipeNope"'), source="broken.mthds")] + + report = _pipe_io(PipeIORequest(files=broken, include_files=True)) + + assert isinstance(report, CrateInvalidReport) + assert report.validation_errors + + def test_a_method_ref_selects_the_manifest_entry_pipe(self) -> None: + report = _pipe_io(PipeIORequest(method_ref=_METHOD_REF, include_files=True)) + + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref is not None + assert report.pipe_ref == report.default_pipe_ref + assert report.files is not None + assert all(item.source is not None and item.source.endswith(".mthds") for item in report.files) + + # ── prepare_inputs on the route ────────────────────────────────── + + def test_prepare_inputs_walks_the_descriptor_the_route_selected(self) -> None: + async def _prepare() -> PreparedInputs: + async with _client() as client: + return await client.prepare_inputs( + files=_ENTRY_FILES, + inputs={"doc": _DOCUMENT_URL, "note": "hi", "dossier": {"title": "t", "cover": "https://example.com/c.png"}}, + ) + + prepared = asyncio.run(_prepare()) + + # Both file positions — the top-level Document and the OPTIONAL nested Image — are + # recognized from the descriptor and wrapped as canonical content; http(s) is not uploaded. + assert prepared.inputs == { + "doc": {"url": _DOCUMENT_URL}, + "note": "hi", + "dossier": {"title": "t", "cover": {"url": "https://example.com/c.png"}}, + } + assert prepared.uploads == [] + + def test_prepare_inputs_by_method_ref_needs_no_pipe_ref(self) -> None: + # The package names its entry pipe in its manifest alone, which the route reads. + async def _prepare() -> PreparedInputs: + async with _client() as client: + return await client.prepare_inputs(method_ref=_METHOD_REF, inputs={}) + + prepared = asyncio.run(_prepare()) + + assert prepared.inputs == {} + assert prepared.uploads == [] + + @pytest.mark.parametrize( + ("files", "pipe_ref"), + [ + (_ENTRY_FILES, "smoke_pipe_io.absent"), + (_NO_ENTRY_FILES, None), + ], + ) + def test_prepare_inputs_maps_a_refused_selection(self, files: list[MthdsFileItem], pipe_ref: str | None) -> None: + # Needs the runner to type the refusal with its entry-lookup `error_type`. + async def _prepare() -> PreparedInputs: + async with _client() as client: + return await client.prepare_inputs(files=files, pipe_ref=pipe_ref, inputs={}) + + with pytest.raises(InputPreparationError, match="the pipe could not be selected"): + asyncio.run(_prepare()) + + # ── The hosted catalog selector ────────────────────────────────── + + @pytest.mark.skipif(not _API_KEY, reason="hosted leg: a stored method needs the platform catalog and PIPELEX_API_KEY") + def test_a_method_id_resolves_through_the_catalog(self) -> None: + async def _by_id() -> tuple[PipeIOResponse, PreparedInputs]: + async with _client() as client: + method = await client.create_method(MethodWriteInput(name=f"sdk-python-e2e-pipe-io-{time.time_ns()}", mthds=_ENTRY_BUNDLE)) + try: + report = await client.pipe_io(PipeIORequest(method_id=method.method_id, include_files=True)) + prepared = await client.prepare_inputs(method_id=method.method_id, inputs={"doc": _DOCUMENT_URL, "note": "hi"}) + finally: + await client.delete_method(method.method_id) + return report, prepared + + report, prepared = asyncio.run(_by_id()) + + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref == "smoke_pipe_io.echo" + assert report.files is not None + assert [item.content for item in report.files] == [_ENTRY_BUNDLE] + assert prepared.inputs == {"doc": {"url": _DOCUMENT_URL}, "note": "hi"} diff --git a/tests/unit/test_crate_routes.py b/tests/unit/test_crate_routes.py index 9f620b9..ec3216f 100644 --- a/tests/unit/test_crate_routes.py +++ b/tests/unit/test_crate_routes.py @@ -1,9 +1,10 @@ -"""The crate routes — `resolve` and `codegen` — and their three-form closure selector. +"""The crate routes — `resolve` and `codegen` — and the three-form closure selector the family shares. Ports the relevant slice of `pipelex-sdk-js/tests/crate-routes.test.ts`: verb + path + body, the 200-verdict discipline (branch on `is_valid`), the strict three-way XOR at request construction, the hosted `method_id` pass-through, and the fetch-sized budget a -`method_ref` closure gets (the server may have to clone before it answers). +`method_ref` closure gets (the server may have to clone before it answers). `pipe_io` has its own +module, `test_pipe_io_route.py`; its request joins the XOR cases here, which pin the whole family. """ import asyncio @@ -15,7 +16,15 @@ from pytest_mock import MockerFixture, MockType from pipelex_sdk.client import PipelexAPIClient -from pipelex_sdk.crate_models import CodegenRequest, CodegenValidReport, CrateInvalidReport, MthdsFileItem, ResolveRequest, ResolveValidReport +from pipelex_sdk.crate_models import ( + CodegenRequest, + CodegenValidReport, + CrateInvalidReport, + MthdsFileItem, + PipeIORequest, + ResolveRequest, + ResolveValidReport, +) from pipelex_sdk.errors import ApiResponseError _BASE_URL = "http://localhost:8081" @@ -135,6 +144,8 @@ def test_request_construction_enforces_exactly_one_selector(self, kwargs: dict[s ResolveRequest.model_validate(kwargs) with pytest.raises(ValidationError, match="exactly one"): CodegenRequest.model_validate({**kwargs, "target": "python-pydantic"}) + with pytest.raises(ValidationError, match="exactly one"): + PipeIORequest.model_validate({**kwargs, "pipe_ref": "demo.main"}) @pytest.mark.parametrize( "kwargs", @@ -156,6 +167,8 @@ def test_empty_selectors_are_absent_and_fail_the_xor(self, kwargs: dict[str, obj ResolveRequest.model_validate(kwargs) with pytest.raises(ValidationError, match="exactly one"): CodegenRequest.model_validate({**kwargs, "target": "python-pydantic"}) + with pytest.raises(ValidationError, match="exactly one"): + PipeIORequest.model_validate({**kwargs, "pipe_ref": "demo.main"}) def test_empty_selector_beside_a_real_one_is_simply_absent(self, mocker: MockerFixture) -> None: """An empty selector beside a real one is absent, not a conflict — exactly-one @@ -164,6 +177,9 @@ def test_empty_selector_beside_a_real_one_is_simply_absent(self, mocker: MockerF request = ResolveRequest(files=[MthdsFileItem(content="x")], method_ref="", method_id=" ") assert request.method_ref is None assert request.method_id is None + pipe_io_request = PipeIORequest(method_ref=" ", method_id="mt_1", files=[]) + assert pipe_io_request.files is None + assert pipe_io_request.method_ref is None client = self._client() send = self._mock_send(mocker, client, _response(200, json_body=_RESOLVE_VALID)) diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py index 81fb6bc..8ffdf7e 100644 --- a/tests/unit/test_data.py +++ b/tests/unit/test_data.py @@ -1,6 +1,6 @@ """Test data constants for the unit suite, grouped by what they stand for.""" -from typing import ClassVar +from typing import Any, ClassVar class RefusedRunBodies: @@ -64,3 +64,115 @@ class RefusedRunBodies: "Pipe 'condense_article' failed (digest_article → condense_article): Model handle 'gpt-5.1' was not found in the model deck." ) UNSERVED_MODEL_NEXT_STEP: ClassVar[str] = "Change the model 'gpt-5.1' to an LLM the model deck serves." + + +class PipeIOBodies: + """`POST /v1/pipe-io` bodies, trimmed from what a local `pipelex-api` at `b4bafb8` (pipelex 0.70.0) + answered on 2026-09-30 for a one-pipe bundle: a Document, a Text, and a structured `Dossier` whose + optional `cover` is an Image. Only the JSON Schemas were shortened; every artifact member the + standard declares is kept, so the bodies parse under the closed `mthds.protocol` models. + """ + + PIPE_REF: ClassVar[str] = "smoke.echo" + TEXT_SCHEMA: ClassVar[dict[str, Any]] = { + "properties": {"text": {"title": "Text", "type": "string"}}, + "required": ["text"], + "title": "native.Text", + "type": "object", + } + VALID: ClassVar[dict[str, Any]] = { + "is_valid": True, + "pipe_ref": "smoke.echo", + "pipe_io_contracts": { + "smoke.echo": { + "inputs": { + "doc": { + "concept_ref": "native.Document", + "presence": "plain", + "multiplicity": "single", + "item_count": None, + "json_schema": {"properties": {"url": {"type": "string"}}, "required": ["url"], "title": "native.Document", "type": "object"}, + }, + "note": { + "concept_ref": "native.Text", + "presence": "plain", + "multiplicity": "single", + "item_count": None, + "json_schema": TEXT_SCHEMA, + }, + }, + "output": {"concept_ref": "native.Text", "multiplicity": "single", "item_count": None, "optional": False, "json_schema": TEXT_SCHEMA}, + } + }, + "input_form": { + "smoke.echo": { + "fields": [ + {"kind": "document", "name": "doc", "concept_ref": "native.Document", "required": True, "presence": "plain", "gating": True}, + {"kind": "prose", "name": "note", "concept_ref": "native.Text", "required": True, "presence": "plain", "gating": True}, + { + "kind": "object", + "name": "dossier", + "concept_ref": "smoke.Dossier", + "required": True, + "presence": "plain", + "gating": True, + "fields": [ + {"kind": "text", "name": "title", "required": True}, + {"kind": "image", "name": "cover", "concept_ref": "native.Image", "required": False}, + ], + }, + ] + } + }, + "output_form": {"smoke.echo": {"field": {"kind": "prose", "name": "output", "concept_ref": "native.Text", "required": True}}}, + "default_pipe_ref": "smoke.echo", + "pending_signatures": [], + "is_runnable": True, + } + INVALID: ClassVar[dict[str, Any]] = { + "is_valid": False, + "validation_errors": [ + { + "category": "blueprint_validation", + "message": "Input 'doc' is declared but never read by the template.", + "error_type": "extraneous_input_variable", + "pipe_code": "echo", + "domain_code": "smoke", + "source": "smoke.mthds", + } + ], + "message": "1 validation error", + } + #: The selection refusal the pipe-selector design asks for: the runner's entry-lookup error class + #: as `error_type`. The runner at `b4bafb8` still answers `error_type: ValidationError` here. + UNKNOWN_PIPE_REFUSAL: ClassVar[dict[str, Any]] = { + "type": "https://docs.pipelex.com/latest/errors/entry-pipe-not-found-error/", + "title": "Entry pipe not found", + "status": 422, + "detail": "Pipe 'smoke.absent' not found in the submitted closure.", + "error_type": "EntryPipeNotFoundError", + "error_domain": "input", + "retryable": False, + "instance": "/v1/pipe-io", + } + AMBIGUOUS_PIPE_REFUSAL: ClassVar[dict[str, Any]] = { + "type": "https://docs.pipelex.com/latest/errors/entry-pipe-ambiguous-error/", + "title": "Entry pipe ambiguous", + "status": 422, + "detail": "No `pipe_ref` was given and the closure declares several `main_pipe`s (alpha.run, beta.run) — name the pipe explicitly.", + "error_type": "EntryPipeAmbiguousError", + "error_domain": "input", + "retryable": False, + "instance": "/v1/pipe-io", + } + #: A `422` that is not a selection: the request-shape refusal the runner renders for a malformed body. + REQUEST_SHAPE_REFUSAL: ClassVar[dict[str, Any]] = { + "type": "https://docs.pipelex.com/latest/errors/validation-error/", + "title": "Validation error", + "status": 422, + "detail": "body: Value error, provide exactly one of `files` or `method_ref`", + "error_type": "ValidationError", + "error_domain": "input", + "retryable": False, + "instance": "/v1/pipe-io", + } diff --git a/tests/unit/test_pipe_io_route.py b/tests/unit/test_pipe_io_route.py new file mode 100644 index 0000000..1780ca8 --- /dev/null +++ b/tests/unit/test_pipe_io_route.py @@ -0,0 +1,195 @@ +"""`pipe_io` — `POST /v1/pipe-io`, the crate route that returns a method's three I/O artifacts. + +The route's own slice of the crate family: verb, path and body; the two 200 arms and the standard's +artifacts typed on the valid one; what raises; and the fetch-sized budget a `method_ref` closure gets. +The selector XOR it shares with `resolve` and `codegen` is pinned for the whole family in +`test_crate_routes.py`. +""" + +import asyncio +import json + +import httpx +import pytest +from mthds.protocol.input_form import ObjectField, PipeInputFormDescriptor +from mthds.protocol.output_form import PipeOutputFormDescriptor +from mthds.protocol.pipe_io_contracts import PipeIOContract, PresenceMarker +from pytest_mock import MockerFixture, MockType + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.crate_models import CrateInvalidReport, MthdsFileItem, PipeIORequest, PipeIOValidReport +from pipelex_sdk.errors import ApiResponseError +from tests.unit.test_data import PipeIOBodies + +_BASE_URL = "http://localhost:8081" +_METHOD_REF = "github.com/Pipelex/methods/documents@v0.1.0" +_FILES = [MthdsFileItem(content='domain = "smoke"', source="smoke.mthds")] + + +def _response(status_code: int, *, json_body: object | None = None) -> httpx.Response: + request = httpx.Request("POST", f"{_BASE_URL}/v1/pipe-io") + if json_body is not None: + return httpx.Response(status_code, json=json_body, request=request) + return httpx.Response(status_code, request=request) + + +class TestPipeIORoute: + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_key="test-token", 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)) + + # ── The request ────────────────────────────────────────────────── + + def test_posts_the_closure_to_the_route(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.VALID)) + + asyncio.run(client.pipe_io(PipeIORequest(files=_FILES))) + + call = send.call_args + assert call.args[0] == "POST" + assert call.args[1] == f"{_BASE_URL}/v1/pipe-io" + # The opt-ins ride at their defaults; an absent `pipe_ref` is not sent, so the server's chain selects. + assert json.loads(call.kwargs["content"]) == { + "files": [{"content": 'domain = "smoke"', "source": "smoke.mthds"}], + "all_pipes": False, + "include_files": False, + } + + def test_the_pipe_selector_and_the_opt_ins_ride_the_body(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.VALID)) + + asyncio.run(client.pipe_io(PipeIORequest(method_ref=_METHOD_REF, pipe_ref="smoke.echo", all_pipes=True, include_files=True))) + + assert json.loads(send.call_args.kwargs["content"]) == { + "method_ref": _METHOD_REF, + "pipe_ref": "smoke.echo", + "all_pipes": True, + "include_files": True, + } + + def test_method_id_is_a_pure_pass_through(self, mocker: MockerFixture) -> None: + """Nothing is expanded client-side: the id rides the body alone and the platform resolves it.""" + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.VALID)) + + asyncio.run(client.pipe_io(PipeIORequest(method_id="mt_1"))) + + assert json.loads(send.call_args.kwargs["content"]) == {"method_id": "mt_1", "all_pipes": False, "include_files": False} + + # ── The valid arm ──────────────────────────────────────────────── + + def test_the_valid_arm_types_the_standards_artifacts(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.VALID)) + + report = asyncio.run(client.pipe_io(PipeIORequest(files=_FILES))) + + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref == PipeIOBodies.PIPE_REF + assert report.default_pipe_ref == PipeIOBodies.PIPE_REF + assert report.pending_signatures == [] + assert report.is_runnable is True + assert report.files is None + contract = report.pipe_io_contracts[PipeIOBodies.PIPE_REF] + assert isinstance(contract, PipeIOContract) + assert contract.inputs["doc"].presence == PresenceMarker.PLAIN + descriptor = report.input_form[PipeIOBodies.PIPE_REF] + assert isinstance(descriptor, PipeInputFormDescriptor) + assert [field.name for field in descriptor.fields] == ["doc", "note", "dossier"] + dossier = descriptor.fields[2] + assert isinstance(dossier, ObjectField) + assert [(field.name, field.required) for field in dossier.fields] == [("title", True), ("cover", False)] + output = report.output_form[PipeIOBodies.PIPE_REF] + assert isinstance(output, PipeOutputFormDescriptor) + assert output.field.name == "output" + + def test_the_files_echo_and_stated_nulls_parse(self, mocker: MockerFixture) -> None: + """Under `all_pipes` with no entry pipe, `pipe_ref` and `default_pipe_ref` are stated `null`s, and + the `include_files` echo keeps a file's absent `source` absent. + """ + client = self._client() + body = { + **PipeIOBodies.VALID, + "pipe_ref": None, + "default_pipe_ref": None, + "pending_signatures": ["smoke.draft"], + "is_runnable": False, + "files": [{"content": 'domain = "smoke"', "source": "smoke.mthds"}, {"content": "x"}], + } + self._mock_send(mocker, client, _response(200, json_body=body)) + + report = asyncio.run(client.pipe_io(PipeIORequest(files=_FILES, all_pipes=True, include_files=True))) + + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref is None + assert report.default_pipe_ref is None + assert report.pending_signatures == ["smoke.draft"] + assert report.is_runnable is False + assert report.files == [MthdsFileItem(content='domain = "smoke"', source="smoke.mthds"), MthdsFileItem(content="x")] + + def test_the_valid_arm_is_extension_open(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, _response(200, json_body={**PipeIOBodies.VALID, "future_member": 1})) + + report = asyncio.run(client.pipe_io(PipeIORequest(files=_FILES))) + + assert isinstance(report, PipeIOValidReport) + assert report.model_extra == {"future_member": 1} + + # ── The invalid arm and the no-verdict conditions ─────────────── + + def test_an_invalid_closure_is_a_200_verdict(self, mocker: MockerFixture) -> None: + client = self._client() + self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.INVALID)) + + report = asyncio.run(client.pipe_io(PipeIORequest(files=_FILES, include_files=True))) + + assert isinstance(report, CrateInvalidReport) + assert report.validation_errors[0].pipe_code == "echo" + + @pytest.mark.parametrize( + ("status", "body", "error_type"), + [ + (422, PipeIOBodies.UNKNOWN_PIPE_REFUSAL, "EntryPipeNotFoundError"), + (422, PipeIOBodies.AMBIGUOUS_PIPE_REFUSAL, "EntryPipeAmbiguousError"), + (422, PipeIOBodies.REQUEST_SHAPE_REFUSAL, "ValidationError"), + (404, {"detail": "Unknown method", "code": "not_found"}, None), + ], + ) + def test_a_no_verdict_answer_raises_api_response_error( + self, mocker: MockerFixture, status: int, body: dict[str, object], error_type: str | None + ) -> None: + """A refused selection is a request-shape `422`, never an `is_valid: false` verdict; the route + itself does not translate it, so the runner's `error_type` stays readable on the error. + """ + client = self._client() + self._mock_send(mocker, client, _response(status, json_body=body)) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.pipe_io(PipeIORequest(files=_FILES, pipe_ref="smoke.absent"))) + assert exc_info.value.status == status + assert exc_info.value.error_type == error_type + + # ── The fetch-sized budget ─────────────────────────────────────── + + def test_a_method_ref_closure_gets_the_fetch_budget(self, mocker: MockerFixture) -> None: + """Resolving an address can make the server clone a repository before it answers.""" + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.VALID)) + + asyncio.run(client.pipe_io(PipeIORequest(method_ref=_METHOD_REF))) + + assert send.call_args.kwargs["request_timeout"] == 180.0 + + @pytest.mark.parametrize("request_body", [PipeIORequest(files=_FILES), PipeIORequest(method_id="mt_1")]) + def test_inline_and_by_id_closures_keep_the_management_budget(self, mocker: MockerFixture, request_body: PipeIORequest) -> None: + client = self._client() + send = self._mock_send(mocker, client, _response(200, json_body=PipeIOBodies.VALID)) + + asyncio.run(client.pipe_io(request_body)) + + assert send.call_args.kwargs["request_timeout"] == 30.0 diff --git a/tests/unit/test_prepare_inputs.py b/tests/unit/test_prepare_inputs.py index e8402d7..95b3caf 100644 --- a/tests/unit/test_prepare_inputs.py +++ b/tests/unit/test_prepare_inputs.py @@ -6,8 +6,9 @@ in `url`, http(s)/storage references pass through, dedup keys on source identity, and the call is copy-on-write. -The fake client returns a canned `PipelexValidationReport` from `validate` and records the call, -so the request shape is asserted and not just the outcome; one wiring test drives the real client. +The fake client returns a canned `/v1/pipe-io` answer from `pipe_io` and records the request, so the +request shape is asserted and not just the outcome; the wiring tests drive the real client, including +the typed selection refusal it must parse off the wire. """ import asyncio @@ -33,11 +34,11 @@ from pytest_mock import MockerFixture from pipelex_sdk.client import PipelexAPIClient -from pipelex_sdk.crate_models import MthdsFileItem +from pipelex_sdk.crate_models import CrateInvalidReport, MthdsFileItem, PipeIORequest, PipeIOResponse, PipeIOValidReport from pipelex_sdk.errors import ApiResponseError, InputPreparationError, RejectedAssetError from pipelex_sdk.prepare_inputs import prepare_inputs from pipelex_sdk.product_models import UploadedFile, UploadInput -from pipelex_sdk.validation_models import PipelexInvalidReport, PipelexValidationReport, PipelexValidationResult +from tests.unit.test_data import PipeIOBodies _BASE_URL = "http://localhost:8081" _FILES = [MthdsFileItem(content='domain = "demo"')] @@ -58,51 +59,51 @@ def _form(*fields: InputFormField, pipe_ref: str = _PIPE_REF) -> dict[str, PipeI return {pipe_ref: PipeInputFormDescriptor(fields=list(fields))} -def _report( - input_form: dict[str, PipeInputFormDescriptor] | None, - *, - bundle_blueprint: dict[str, Any] | None = None, - default_pipe_ref: str | None = None, -) -> PipelexValidationReport: - return PipelexValidationReport( +def _report(input_form: dict[str, PipeInputFormDescriptor], *, pipe_ref: str | None = _PIPE_REF) -> PipeIOValidReport: + """A single-pipe valid answer: the route resolved `pipe_ref` and keyed the descriptor by it.""" + return PipeIOValidReport( is_valid=True, - bundle_blueprint=bundle_blueprint if bundle_blueprint is not None else {}, - default_pipe_ref=default_pipe_ref, + pipe_ref=pipe_ref, + pipe_io_contracts={}, input_form=input_form, + output_form={}, + default_pipe_ref=pipe_ref, + pending_signatures=[], + is_runnable=True, ) +def _api_error(status: int, body: dict[str, Any]) -> ApiResponseError: + """The `ApiResponseError` the real client raises for this answer, built through its own error seam.""" + client = PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) + response = httpx.Response(status, json=body, request=httpx.Request("POST", f"{_BASE_URL}/v1/pipe-io")) + with pytest.raises(ApiResponseError) as exc_info: + client._raise_api_response_error(method="POST", endpoint="pipe-io", response=response) + return exc_info.value + + class _FakePrepareClient: - """Fake client: `validate` returns the given report and records the call; `upload` counts calls.""" + """Fake client: `pipe_io` returns the given answer (or raises) and records the request; `upload` counts calls.""" - def __init__(self, result: PipelexValidationResult, *, upload_error: Exception | None = None) -> None: + def __init__( + self, + result: PipeIOResponse | None = None, + *, + pipe_io_error: ApiResponseError | None = None, + upload_error: Exception | None = None, + ) -> None: self._result = result + self._pipe_io_error = pipe_io_error self._upload_error = upload_error self.upload_calls: list[UploadInput] = [] - self.validate_calls: list[dict[str, Any]] = [] + self.pipe_io_calls: list[PipeIORequest] = [] self._counter = 0 - async def validate( - self, - mthds_contents: list[str] | None = None, - allow_signatures: bool = False, - mthds_sources: list[str] | None = None, - render: list[str] | None = None, - views: list[str] | None = None, - *, - method_ref: str | None = None, - method_id: str | None = None, - ) -> PipelexValidationResult: - self.validate_calls.append( - { - "mthds_contents": mthds_contents, - "allow_signatures": allow_signatures, - "mthds_sources": mthds_sources, - "views": views, - "method_ref": method_ref, - "method_id": method_id, - } - ) + async def pipe_io(self, request: PipeIORequest) -> PipeIOResponse: + self.pipe_io_calls.append(request) + if self._pipe_io_error is not None: + raise self._pipe_io_error + assert self._result is not None return self._result async def upload(self, upload_input: UploadInput) -> UploadedFile: @@ -120,45 +121,40 @@ def _image_client(name: str = "photo", **upload_error: Any) -> _FakePrepareClien class TestPrepareInputs: # ── The signature call ──────────────────────────────────────────────── - def test_asks_validate_for_the_input_form_view(self) -> None: + def test_asks_pipe_io_for_the_pipe_the_route_selects(self) -> None: client = _image_client() asyncio.run(prepare_inputs(client, files=_FILES, inputs={})) - call = client.validate_calls[0] - assert call["views"] == ["input_form"] - assert call["allow_signatures"] is True - assert call["mthds_contents"] == ['domain = "demo"'] - # No file names a source, so none is synthesized — the server never sees a - # length-mismatched `mthds_sources` array. - assert call["mthds_sources"] is None + # One call, one pipe, no echo: the route selects the pipe when none is named. + assert client.pipe_io_calls == [PipeIORequest(files=_FILES)] + request = client.pipe_io_calls[0] + assert request.pipe_ref is None + assert request.all_pipes is False + assert request.include_files is False - def test_labels_every_content_once_any_file_names_a_source(self) -> None: + def test_passes_the_files_through_as_given(self) -> None: + # The route takes the crate envelope, so each file keeps its own `source` — none is synthesized. client = _image_client() files = [MthdsFileItem(content="a"), MthdsFileItem(content="b", source="b.mthds")] asyncio.run(prepare_inputs(client, files=files, inputs={})) - assert client.validate_calls[0]["mthds_sources"] == ["inline://file-1.mthds", "b.mthds"] + assert client.pipe_io_calls[0].files == files def test_method_ref_is_a_server_side_pass_through(self) -> None: client = _image_client() asyncio.run(prepare_inputs(client, method_ref="github.com/Pipelex/methods/documents", inputs={})) - call = client.validate_calls[0] - assert call["method_ref"] == "github.com/Pipelex/methods/documents" - assert call["mthds_contents"] is None - assert call["views"] == ["input_form"] + assert client.pipe_io_calls == [PipeIORequest(method_ref="github.com/Pipelex/methods/documents")] def test_method_id_is_a_server_side_pass_through(self) -> None: client = _image_client() asyncio.run(prepare_inputs(client, method_id="mt_abc123", inputs={})) - call = client.validate_calls[0] - assert call["method_id"] == "mt_abc123" - assert call["mthds_contents"] is None + assert client.pipe_io_calls == [PipeIORequest(method_id="mt_abc123")] # ── The three selectors ─────────────────────────────────────────────── @@ -167,7 +163,7 @@ def test_no_selector_is_refused_before_any_request(self) -> None: with pytest.raises(InputPreparationError, match="no method selector"): asyncio.run(prepare_inputs(client, inputs={"photo": bytes([1])})) - assert client.validate_calls == [] + assert client.pipe_io_calls == [] assert client.upload_calls == [] @pytest.mark.parametrize( @@ -183,7 +179,7 @@ def test_several_selectors_are_refused_before_any_request(self, kwargs: dict[str with pytest.raises(InputPreparationError, match="exactly one method selector"): asyncio.run(prepare_inputs(client, inputs={}, **kwargs)) - assert client.validate_calls == [] + assert client.pipe_io_calls == [] def test_empty_selectors_are_absent_beside_a_real_one(self) -> None: # `files=[]` and a blank `method_id` select nothing, so they may sit beside a real @@ -192,7 +188,7 @@ def test_empty_selectors_are_absent_beside_a_real_one(self) -> None: asyncio.run(prepare_inputs(client, files=[], method_ref="github.com/o/r", method_id=" ", inputs={})) - assert client.validate_calls[0]["method_ref"] == "github.com/o/r" + assert client.pipe_io_calls == [PipeIORequest(method_ref="github.com/o/r")] def test_only_empty_selectors_is_no_selector(self) -> None: client = _image_client() @@ -217,90 +213,129 @@ def test_a_non_string_selector_is_refused_rather_than_read_as_absent(self, kwarg asyncio.run(prepare_inputs(client, files=_FILES, inputs={}, **kwargs)) assert str(exc_info.value) == f"Cannot prepare inputs: `{argument}` must be a string, got {type_name}." - assert client.validate_calls == [] + assert client.pipe_io_calls == [] assert client.upload_calls == [] def test_a_non_string_pipe_ref_is_refused_rather_than_silently_defaulted(self) -> None: - # Read as absent, it would be absorbed by the single-declared-pipe default: the pipe - # the caller named would vanish without a word. Refused on the pre-request boundary. + # Read as absent, it would let the route select the default pipe: the pipe the caller + # named would vanish without a word. Refused on the pre-request boundary. client = _image_client() with pytest.raises(InputPreparationError) as exc_info: asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref=cast("str", 123), inputs={})) assert str(exc_info.value) == "Cannot prepare inputs: `pipe_ref` must be a string, got int." - assert client.validate_calls == [] + assert client.pipe_io_calls == [] - # ── Pipe selection ──────────────────────────────────────────────────── + # ── Pipe selection: the route's ─────────────────────────────────────── - def test_uses_the_single_declared_pipe_when_no_ref_is_given(self) -> None: - client = _image_client() + def test_reads_the_descriptor_of_the_pipe_the_route_resolved(self) -> None: + # No `pipe_ref`: the route's chain picks `demo.second`, and the walk follows its + # descriptor, which declares `photo` as an image. + client = _FakePrepareClient(_report(_form(ImageField(name="photo", **_required()), pipe_ref="demo.second"), pipe_ref="demo.second")) prepared = asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) assert prepared.inputs == {"photo": {"url": "pipelex-storage://user/assets/1.bin"}} - def test_typed_default_pipe_ref_outranks_the_blueprint(self) -> None: - input_form = { - "demo.first": PipeInputFormDescriptor(fields=[TextField(name="photo", **_required())]), - "demo.second": PipeInputFormDescriptor(fields=[ImageField(name="photo", **_required())]), - } - client = _FakePrepareClient(_report(input_form, bundle_blueprint={"domain": "demo", "main_pipe": "first"}, default_pipe_ref="demo.second")) + def test_an_explicit_pipe_ref_is_sent_to_the_route(self) -> None: + client = _FakePrepareClient(_report(_form(ImageField(name="photo", **_required()), pipe_ref="demo.second"), pipe_ref="demo.second")) - prepared = asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) + prepared = asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref=" demo.second ", inputs={"photo": bytes([1])})) - # `demo.second` declares `photo` as an image; `demo.first` declares it as text. + # Trimmed on the way out, like every caller-supplied selector. + assert client.pipe_io_calls == [PipeIORequest(files=_FILES, pipe_ref="demo.second")] assert prepared.inputs == {"photo": {"url": "pipelex-storage://user/assets/1.bin"}} - def test_falls_back_to_the_blueprint_main_pipe_qualified_by_its_domain(self) -> None: - input_form = { - "demo.first": PipeInputFormDescriptor(fields=[ImageField(name="photo", **_required())]), - "demo.second": PipeInputFormDescriptor(fields=[TextField(name="photo", **_required())]), - } - client = _FakePrepareClient(_report(input_form, bundle_blueprint={"domain": "demo", "main_pipe": "first"})) + def test_a_bare_pipe_ref_is_refused_before_any_request(self) -> None: + # The runner would still resolve a bare code across domains; preparation is + # qualified-only, so it refuses before spending the round-trip. + client = _image_client() - prepared = asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) + with pytest.raises(InputPreparationError) as exc_info: + asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref="main", inputs={})) - assert prepared.inputs == {"photo": {"url": "pipelex-storage://user/assets/1.bin"}} + assert str(exc_info.value) == 'Cannot prepare inputs: `pipe_ref` must be qualified (`domain.pipe_code`), got the bare "main".' + assert client.pipe_io_calls == [] - def test_explicit_pipe_ref_wins(self) -> None: - input_form = { - "demo.first": PipeInputFormDescriptor(fields=[TextField(name="photo", **_required())]), - "demo.second": PipeInputFormDescriptor(fields=[ImageField(name="photo", **_required())]), - } - client = _FakePrepareClient(_report(input_form, default_pipe_ref="demo.first")) + @pytest.mark.parametrize( + ("body", "detail"), + [ + (PipeIOBodies.UNKNOWN_PIPE_REFUSAL, "Pipe 'smoke.absent' not found in the submitted closure."), + ( + PipeIOBodies.AMBIGUOUS_PIPE_REFUSAL, + "No `pipe_ref` was given and the closure declares several `main_pipe`s (alpha.run, beta.run) — name the pipe explicitly.", + ), + ], + ) + def test_a_refused_selection_is_an_input_preparation_error(self, body: dict[str, Any], detail: str) -> None: + refusal = _api_error(422, body) + client = _FakePrepareClient(pipe_io_error=refusal) - prepared = asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref="demo.second", inputs={"photo": bytes([1])})) + with pytest.raises(InputPreparationError) as exc_info: + asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) - assert prepared.inputs == {"photo": {"url": "pipelex-storage://user/assets/1.bin"}} + assert str(exc_info.value) == f"Cannot prepare inputs: the pipe could not be selected — {detail}" + # The whole problem document stays reachable for a caller who needs it. + assert exc_info.value.__cause__ is refusal + assert client.upload_calls == [] - def test_bare_pipe_ref_is_refused_naming_the_qualified_candidates(self) -> None: - client = _image_client() + def test_a_candidate_list_the_body_carries_is_named(self) -> None: + body = {**PipeIOBodies.UNKNOWN_PIPE_REFUSAL, "candidates": ["smoke.echo", "smoke.draft", 7]} + client = _FakePrepareClient(pipe_io_error=_api_error(422, body)) - with pytest.raises(InputPreparationError, match="must be qualified") as exc_info: - asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref="main", inputs={})) - assert _PIPE_REF in str(exc_info.value) + with pytest.raises(InputPreparationError) as exc_info: + asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref="smoke.absent", inputs={})) - def test_unknown_pipe_ref_is_refused_naming_the_candidates(self) -> None: - client = _image_client() + # A non-string member is dropped rather than trusted. + assert str(exc_info.value).endswith("not found in the submitted closure. Candidates: smoke.echo, smoke.draft.") - with pytest.raises(InputPreparationError, match="declares no pipe") as exc_info: - asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref="demo.absent", inputs={})) - assert _PIPE_REF in str(exc_info.value) - - def test_several_pipes_and_no_default_is_an_honest_refusal(self) -> None: - # The manifest-only `main_pipe` gap: a fetched package may name its entry pipe in - # METHODS.toml alone, which the report never carries. The error lists the candidates - # so the caller's fix is one line. - input_form = { - "demo.first": PipeInputFormDescriptor(fields=[]), - "demo.second": PipeInputFormDescriptor(fields=[]), - } - client = _FakePrepareClient(_report(input_form)) + def test_candidates_the_detail_already_names_are_not_repeated(self) -> None: + body = {**PipeIOBodies.AMBIGUOUS_PIPE_REFUSAL, "candidates": ["alpha.run", "beta.run"]} + client = _FakePrepareClient(pipe_io_error=_api_error(422, body)) + + with pytest.raises(InputPreparationError) as exc_info: + asyncio.run(prepare_inputs(client, files=_FILES, inputs={})) + + assert "Candidates:" not in str(exc_info.value) + + @pytest.mark.parametrize( + ("status", "body"), + [ + (422, PipeIOBodies.REQUEST_SHAPE_REFUSAL), + (422, {**PipeIOBodies.UNKNOWN_PIPE_REFUSAL, "error_type": "MethodRefFetchError"}), + (404, {"detail": "Unknown method", "code": "not_found"}), + (500, {"detail": "PipeIOContractError", "error_type": "PipeIOContractError"}), + ], + ) + def test_every_other_error_is_left_as_it_is(self, status: int, body: dict[str, Any]) -> None: + # Only the entry-lookup errors are a selection; a `422` of any other type is not. + error = _api_error(status, body) + client = _FakePrepareClient(pipe_io_error=error) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(prepare_inputs(client, method_id="mt_1", inputs={})) + + assert exc_info.value is error + + @pytest.mark.parametrize( + ("input_form", "pipe_ref"), + [ + ({}, _PIPE_REF), + (_form(ImageField(name="photo", **_required()), pipe_ref="demo.other"), _PIPE_REF), + (_form(ImageField(name="photo", **_required())), None), + ], + ) + def test_an_answer_without_the_selected_descriptor_is_an_error_not_a_silent_no_op( + self, input_form: dict[str, PipeInputFormDescriptor], pipe_ref: str | None + ) -> None: + # Never a silent degrade to "no uploads": without the descriptor there is no signature + # to prepare against, and the caller's local path would travel to the runner verbatim. + client = _FakePrepareClient(_report(input_form, pipe_ref=pipe_ref)) - with pytest.raises(InputPreparationError, match="no single default pipe") as exc_info: - asyncio.run(prepare_inputs(client, method_ref="github.com/Pipelex/methods/documents", inputs={})) - assert "demo.first, demo.second" in str(exc_info.value) + with pytest.raises(InputPreparationError, match="carries no input-form descriptor for the selected pipe"): + asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) + assert client.upload_calls == [] # ── The descriptor-guided walk ──────────────────────────────────────── @@ -555,20 +590,16 @@ def test_raises_for_unrecognized_value_at_file_position(self) -> None: assert client.upload_calls == [] def test_raises_when_the_signature_does_not_resolve(self) -> None: - invalid = PipelexInvalidReport(is_valid=False, message="closure did not validate", validation_errors=[]) + invalid = CrateInvalidReport.model_validate(PipeIOBodies.INVALID) client = _FakePrepareClient(invalid) - with pytest.raises(InputPreparationError, match="the method signature did not resolve"): + with pytest.raises(InputPreparationError) as exc_info: asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) - def test_a_report_without_the_descriptor_is_an_error_not_a_silent_no_op(self) -> None: - # Never a silent degrade to "no uploads": without the descriptor there is no signature - # to prepare against, and the caller's local path would travel to the runner verbatim. - client = _FakePrepareClient(_report(None)) - - with pytest.raises(InputPreparationError, match="carries no `input_form` descriptor"): - asyncio.run(prepare_inputs(client, files=_FILES, inputs={"photo": bytes([1])})) - assert client.upload_calls == [] + assert ( + str(exc_info.value) + == "Cannot prepare inputs: the method signature did not resolve — Input 'doc' is declared but never read by the template." + ) def test_surfaces_rejected_asset_before_returning(self) -> None: error = ApiResponseError( @@ -583,11 +614,6 @@ def test_surfaces_rejected_asset_before_returning(self) -> None: def test_wires_through_the_real_client(self, mocker: MockerFixture) -> None: client = PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) - validate_body = { - "is_valid": True, - "bundle_blueprint": {}, - "input_form": {_PIPE_REF: {"fields": [{"kind": "image", "name": "photo", "required": True, "presence": "plain", "gating": True}]}}, - } upload_body = {"uri": "pipelex-storage://user/assets/1.bin", "filename": "upload.bin"} request = httpx.Request("POST", f"{_BASE_URL}/x") send = mocker.patch.object( @@ -595,32 +621,64 @@ def test_wires_through_the_real_client(self, mocker: MockerFixture) -> None: "_send", mocker.AsyncMock( side_effect=[ - httpx.Response(200, json=validate_body, request=request), + httpx.Response(200, json=PipeIOBodies.VALID, request=request), httpx.Response(200, json=upload_body, request=request), ] ), ) - prepared = asyncio.run(client.prepare_inputs(files=_FILES, inputs={"photo": bytes([1, 2, 3])})) + prepared = asyncio.run( + client.prepare_inputs( + files=[MthdsFileItem(content='domain = "smoke"', source="smoke.mthds")], + inputs={"doc": bytes([1, 2, 3]), "note": "hi", "dossier": {"title": "t"}}, + ) + ) - assert prepared.inputs == {"photo": {"url": "pipelex-storage://user/assets/1.bin"}} + assert prepared.inputs == {"doc": {"url": "pipelex-storage://user/assets/1.bin"}, "note": "hi", "dossier": {"title": "t"}} assert len(prepared.uploads) == 1 - assert send.await_args_list[0].args[1] == f"{_BASE_URL}/v1/validate" + first_call = send.await_args_list[0] + assert first_call.args[1] == f"{_BASE_URL}/v1/pipe-io" + assert json.loads(first_call.kwargs["content"]) == { + "files": [{"content": 'domain = "smoke"', "source": "smoke.mthds"}], + "all_pipes": False, + "include_files": False, + } + assert first_call.kwargs["request_timeout"] == 30.0 def test_wires_a_method_ref_through_the_real_client(self, mocker: MockerFixture) -> None: client = PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) - validate_body = { - "is_valid": True, - "bundle_blueprint": {}, - "input_form": {_PIPE_REF: {"fields": [{"kind": "text", "name": "question", "required": True, "presence": "plain", "gating": True}]}}, - } request = httpx.Request("POST", f"{_BASE_URL}/x") - send = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=httpx.Response(200, json=validate_body, request=request))) + send = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=httpx.Response(200, json=PipeIOBodies.VALID, request=request))) + + asyncio.run(client.prepare_inputs(method_ref="github.com/o/r", pipe_ref="smoke.echo", inputs={"note": "hi"})) + + call = send.await_args_list[0] + assert json.loads(call.kwargs["content"]) == { + "method_ref": "github.com/o/r", + "pipe_ref": "smoke.echo", + "all_pipes": False, + "include_files": False, + } + # The server may clone the repository before it answers. + assert call.kwargs["request_timeout"] == 180.0 + + def test_wires_a_refused_selection_off_the_wire(self, mocker: MockerFixture) -> None: + # The mapping reads `error_type` off the problem document the real client parses. + client = PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) + request = httpx.Request("POST", f"{_BASE_URL}/v1/pipe-io") + mocker.patch.object( + client, + "_send", + mocker.AsyncMock( + return_value=httpx.Response( + 422, json=PipeIOBodies.UNKNOWN_PIPE_REFUSAL, headers={"content-type": "application/problem+json"}, request=request + ) + ), + ) - asyncio.run(client.prepare_inputs(method_ref="github.com/o/r", inputs={"question": "hi"})) + with pytest.raises(InputPreparationError, match="the pipe could not be selected") as exc_info: + asyncio.run(client.prepare_inputs(files=_FILES, pipe_ref="smoke.absent", inputs={})) - body = json.loads(send.await_args_list[0].kwargs["content"]) - assert body["method_ref"] == "github.com/o/r" - assert body["views"] == ["input_form"] - assert body["allow_signatures"] is True - assert "mthds_contents" not in body + cause = exc_info.value.__cause__ + assert isinstance(cause, ApiResponseError) + assert cause.error_type == "EntryPipeNotFoundError" From 6f1b7d4ae8fc970307ccc825f0fdf2a37921735b Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Wed, 30 Sep 2026 11:16:08 +0200 Subject: [PATCH 2/6] Say what prepare_inputs needs from the runner, and correct stale docs The changelog now names the address-based cross-package dependency the route refuses, and the docs say that a runner which renders a refused selection as a generic ValidationError is answered with ApiResponseError. The README example narrows pipe_ref before indexing, and the validate report's default_pipe_ref docstring no longer describes the blueprint fallback prepare_inputs dropped, nor the "none or several" rule validate never followed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EWwzmAGtZvzvQqeC1SvzJ1 --- CHANGELOG.md | 2 +- README.md | 2 +- docs/architecture.md | 2 +- docs/input-preparation.md | 2 +- pipelex_sdk/prepare_inputs.py | 7 ++++--- pipelex_sdk/validation_models.py | 7 ++++--- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 844ad3d..91fd0a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Changed -- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A refused selection raises `InputPreparationError` with the server's reason, and the route runs no dry run, so a method whose dry run fails still prepares. +- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A selection the API refuses with the runner's entry-lookup `error_type` raises `InputPreparationError` with the server's reason. The route runs no dry run, so a method whose dry run fails still prepares, but it refuses an address-based cross-package dependency that `validate` loaded, so a closure carrying one no longer prepares. ## [v0.14.0] - 2026-09-27 diff --git a/README.md b/README.md index 16fc79c..ad09b08 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ report = await client.validate(method_id="mt_123") from pipelex_sdk.crate_models import PipeIORequest, PipeIOValidReport report = await client.pipe_io(PipeIORequest(method_ref="github.com/Pipelex/methods/documents@v0.1.0")) -if isinstance(report, PipeIOValidReport): +if isinstance(report, PipeIOValidReport) and report.pipe_ref is not None: descriptor = report.input_form[report.pipe_ref] print([field.name for field in descriptor.fields], report.is_runnable) ``` diff --git a/docs/architecture.md b/docs/architecture.md index 7fa6525..252abb4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -155,7 +155,7 @@ The override reuses the inherited base transport seam `_post_validate` (which bu **Validate error regime:** a *no-verdict* non-2xx raises `ApiResponseError`, as the JS `validate` does. This SDK once kept `httpx.HTTPStatusError` there so that `validate` would match the other inherited protocol routes; since `mthds` raises `ApiResponseError` from every route and this client overrides the seam it raises through, the same consistency argument now puts every route, `validate` included, on the typed error. The verdict itself (valid/invalid) is always a 200 either way. -**What the report carries.** A valid `PipelexValidationReport` adds typed fields beyond the protocol base. `warnings: list[ValidationErrorItem]` are advisory lints on a bundle that is nonetheless valid — the same item type as `validation_errors[]`, so one parser serves both channels, but they never flip `is_valid` (this is where the `hint_*` error types ride). `liftable_pipes: list[LiftablePipeEntry]` inventories the pipes the runtime may skip when an optional slot resolves absent. `input_form: InputForm | None` and `output_form: OutputForm | None` carry the per-pipe input- and output-form descriptors, keyed exactly like `pipe_io_contracts`, each present only when the request named that view. `default_pipe_ref: str | None` is the qualified `pipe_ref` a caller gets by omitting the pipe selector, `None` when the closure declares none or several — manifest-aware for a fetched package, which is what makes it outrank a `bundle_blueprint` read; `docs/input-preparation.md` walks the fallback ladder `prepare_inputs` runs when a runner predates it. The lists default empty and the optionals default `None`, so a body from an older runner still parses; an empty list is also what a clean bundle yields, so no caller can tell the two apart. On the view fields the same default says something stronger: an opt-in view's absence means the request did not ask for it, never that the method has nothing to describe. `PipelexInvalidReport` gains none of them — they all derive from a crate that was never assembled. +**What the report carries.** A valid `PipelexValidationReport` adds typed fields beyond the protocol base. `warnings: list[ValidationErrorItem]` are advisory lints on a bundle that is nonetheless valid — the same item type as `validation_errors[]`, so one parser serves both channels, but they never flip `is_valid` (this is where the `hint_*` error types ride). `liftable_pipes: list[LiftablePipeEntry]` inventories the pipes the runtime may skip when an optional slot resolves absent. `input_form: InputForm | None` and `output_form: OutputForm | None` carry the per-pipe input- and output-form descriptors, keyed exactly like `pipe_io_contracts`, each present only when the request named that view. `default_pipe_ref: str | None` is the qualified `pipe_ref` a selector-less run would execute, `None` when the closure declares no `main_pipe` — manifest-aware for a fetched package, which is what makes it outrank a `bundle_blueprint` read. It is the run default, which names the first declaring domain's `main_pipe` where several declare one; `pipe_io`'s field of the same name states `None` there instead, and `prepare_inputs` reads neither, since the route selects the pipe itself. The lists default empty and the optionals default `None`, so a body from an older runner still parses; an empty list is also what a clean bundle yields, so no caller can tell the two apart. On the view fields the same default says something stronger: an opt-in view's absence means the request did not ask for it, never that the method has nothing to describe. `PipelexInvalidReport` gains none of them — they all derive from a crate that was never assembled. `ValidationErrorItem` gains `missing_pipe_code` (symmetrical with `missing_concept_code`) and `suggested_fix: SuggestedFix | None` — a deterministic repair proposal with a `fix_code`, a `description`, a `FixSafety` (`safe` / `unsafe`, with an `is_safe` property), an optional `source`, and `ops`: a list discriminated on `kind` over the closed `FixOpKind` vocabulary (`set_key`, `ensure_table`, `delete_key`, `delete_table`, `rename_table_key`, `move_key`, `remap_value`), narrowed with an exhaustive `match op: case SetKeyOp(): …`. The ops are **reader** models here: `extra="allow"`, no `frozen`, none of the runtime's wildcard-refusing validators, because this SDK only reads fixes where the runtime plans them. A `kind` this SDK does not know fails the whole verdict parse, deliberately and consistently with `ValidationErrorCategory`. `error_type` stays an open `str`: the runtime union keeps gaining advisory members, and closing it here would turn every runtime addition into an SDK break. diff --git a/docs/input-preparation.md b/docs/input-preparation.md index 885ce70..5da9a92 100644 --- a/docs/input-preparation.md +++ b/docs/input-preparation.md @@ -88,7 +88,7 @@ The route selects the pipe, and the helper keeps no selection chain of its own: 2. **A fetched package manifest's `main_pipe`**, for a `method_ref`: a package that names its entry pipe in `METHODS.toml` alone needs no `pipe_ref`. 3. **The closure's own `main_pipe` declaration**, when exactly one domain declares one. -The chain stops at the first link that is present. A method that declares no entry pipe, or several, needs an explicit `pipe_ref` — there is no fall-back to "the single pipe", and nothing reads the bundle blueprint. **A refused selection is an `InputPreparationError`** carrying the server's reason (and the candidate refs, when the answer lists them apart from its reason), with the route's `ApiResponseError` as its `__cause__`. The route marks a refused selection by its `error_type`, the runner's entry-lookup error (`EntryPipeNotFoundError` for an unknown ref or no entry pipe, `EntryPipeAmbiguousError` for several); any other non-2xx — a `method_ref` that does not parse or fetch, an unknown `method_id`, a registry-form address, auth, a server fault — is left as the `ApiResponseError` it is. +The chain stops at the first link that is present. A method that declares no entry pipe, or several, needs an explicit `pipe_ref` — there is no fall-back to "the single pipe", and nothing reads the bundle blueprint. **A refused selection is an `InputPreparationError`** carrying the server's reason (and the candidate refs, when the answer lists them apart from its reason), with the route's `ApiResponseError` as its `__cause__`. The route marks a refused selection by its `error_type`, the runner's entry-lookup error (`EntryPipeNotFoundError` for an unknown ref or no entry pipe, `EntryPipeAmbiguousError` for several); any other non-2xx — a `method_ref` that does not parse or fetch, an unknown `method_id`, a registry-form address, auth, a server fault — is left as the `ApiResponseError` it is. A runner that renders a refused selection as a generic `ValidationError`, as `pipelex-api` did when the route first shipped, is therefore answered with `ApiResponseError` too: the SDK does not guess a selection from a message. ## Compact or explicit-envelope inputs diff --git a/pipelex_sdk/prepare_inputs.py b/pipelex_sdk/prepare_inputs.py index 7b07c05..915d459 100644 --- a/pipelex_sdk/prepare_inputs.py +++ b/pipelex_sdk/prepare_inputs.py @@ -415,9 +415,10 @@ async def prepare_inputs( Raises: InputPreparationError: No selector or several; a selector or `pipe_ref` that is not a string; a bare `pipe_ref`; the closure did not resolve; the route refused the pipe - selection — an unknown `pipe_ref`, or no `pipe_ref` and a method declaring no - single entry pipe — carrying the server's reason, with the `ApiResponseError` as - its `__cause__`; or a value at a file position is unusable. HTTP(S) URLs and + selection with the runner's entry-lookup `error_type` — an unknown `pipe_ref`, or + no `pipe_ref` and a method declaring no single entry pipe — carrying the server's + reason, with the `ApiResponseError` as its `__cause__`; or a value at a file + position is unusable. HTTP(S) URLs and existing `pipelex-storage://` URIs pass through unchanged, and every failure is raised BEFORE any run is created. ApiResponseError: Any other no-verdict condition from `/v1/pipe-io` — an unknown or diff --git a/pipelex_sdk/validation_models.py b/pipelex_sdk/validation_models.py index a5bcf1a..ade00bd 100644 --- a/pipelex_sdk/validation_models.py +++ b/pipelex_sdk/validation_models.py @@ -307,13 +307,14 @@ class PipelexValidationReport(ValidationReport): here could only be a copy free to drift from the runtime that emits it.""" default_pipe_ref: str | None = None - """The qualified `pipe_ref` a caller gets by omitting the pipe selector, or `None` when the - closure declares none or several. + """The qualified `pipe_ref` a selector-less run would execute, or `None` when the closure + declares no `main_pipe` (or a fetched manifest's `main_pipe` resolves to no pipe). Manifest-aware for a fetched package, which is what makes it outrank a `bundle_blueprint` read: a published package may name its entry pipe in `METHODS.toml` alone, and the blueprint never carries a manifest. Optional and read leniently — a runner that predates the field simply sends - nothing, so a consumer falls back (`prepare_inputs` reads the blueprint's `main_pipe` next).""" + nothing. It is the run default, and not `pipe_io`'s field of the same name, which states `None` + where several domains declare a `main_pipe` rather than naming the first.""" pipe_io_contracts: PipeIOContracts = Field(default_factory=dict) """The per-pipe I/O contracts, typed by importing the standard's own client models. From 767b1b2337e3761c22e6c4037d42adf38fd09e32 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Wed, 30 Sep 2026 11:28:07 +0200 Subject: [PATCH 3/6] Map a refused selection to the server's detail alone, and name pipelex-api v0.33.1 The runner types a refused selection with the engine's entry-lookup error_type from pipelex-api v0.33.1, and names the candidates in its detail only, so prepare_inputs no longer reads a speculative candidates member: its message is the server's detail. The unit fixtures are the bodies the fixed runner answers, the docs and changelog name the runner floor, and the e2e suite gains a live ambiguous-selection case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EWwzmAGtZvzvQqeC1SvzJ1 --- CHANGELOG.md | 2 +- docs/input-preparation.md | 2 +- pipelex_sdk/prepare_inputs.py | 55 ++++++++++--------------------- tests/e2e/test_pipe_io_e2e.py | 34 +++++++++++++++---- tests/unit/test_data.py | 17 ++++++---- tests/unit/test_prepare_inputs.py | 19 ----------- 6 files changed, 59 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fd0a7..16b4bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Changed -- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A selection the API refuses with the runner's entry-lookup `error_type` raises `InputPreparationError` with the server's reason. The route runs no dry run, so a method whose dry run fails still prepares, but it refuses an address-based cross-package dependency that `validate` loaded, so a closure carrying one no longer prepares. +- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A selection the API refuses with the runner's entry-lookup `error_type` (`pipelex-api` v0.33.1 and later) raises `InputPreparationError` with the server's reason. The route runs no dry run, so a method whose dry run fails still prepares, but it refuses an address-based cross-package dependency that `validate` loaded, so a closure carrying one no longer prepares. ## [v0.14.0] - 2026-09-27 diff --git a/docs/input-preparation.md b/docs/input-preparation.md index 5da9a92..e3ea53c 100644 --- a/docs/input-preparation.md +++ b/docs/input-preparation.md @@ -88,7 +88,7 @@ The route selects the pipe, and the helper keeps no selection chain of its own: 2. **A fetched package manifest's `main_pipe`**, for a `method_ref`: a package that names its entry pipe in `METHODS.toml` alone needs no `pipe_ref`. 3. **The closure's own `main_pipe` declaration**, when exactly one domain declares one. -The chain stops at the first link that is present. A method that declares no entry pipe, or several, needs an explicit `pipe_ref` — there is no fall-back to "the single pipe", and nothing reads the bundle blueprint. **A refused selection is an `InputPreparationError`** carrying the server's reason (and the candidate refs, when the answer lists them apart from its reason), with the route's `ApiResponseError` as its `__cause__`. The route marks a refused selection by its `error_type`, the runner's entry-lookup error (`EntryPipeNotFoundError` for an unknown ref or no entry pipe, `EntryPipeAmbiguousError` for several); any other non-2xx — a `method_ref` that does not parse or fetch, an unknown `method_id`, a registry-form address, auth, a server fault — is left as the `ApiResponseError` it is. A runner that renders a refused selection as a generic `ValidationError`, as `pipelex-api` did when the route first shipped, is therefore answered with `ApiResponseError` too: the SDK does not guess a selection from a message. +The chain stops at the first link that is present. A method that declares no entry pipe, or several, needs an explicit `pipe_ref` — there is no fall-back to "the single pipe", and nothing reads the bundle blueprint. **A refused selection is an `InputPreparationError`** carrying the server's `detail`, which names the candidate refs where there are any, with the route's `ApiResponseError` as its `__cause__`. The route marks a refused selection by its `error_type`, the runner's entry-lookup error, since `pipelex-api` v0.33.1: `EntryPipeNotFoundError` for an unknown ref, a manifest `main_pipe` the closure lacks, or no ref and no `main_pipe`; `EntryPipeAmbiguousError` for an ambiguous bare code, or no ref and several `main_pipe`s; any other non-2xx — a `method_ref` that does not parse or fetch, an unknown `method_id`, a registry-form address, auth, a server fault — is left as the `ApiResponseError` it is. A runner older than `pipelex-api` v0.33.1 renders a refused selection as a generic `ValidationError`, so it is answered with `ApiResponseError` too: the SDK does not guess a selection from a message. ## Compact or explicit-envelope inputs diff --git a/pipelex_sdk/prepare_inputs.py b/pipelex_sdk/prepare_inputs.py index 915d459..a9e2322 100644 --- a/pipelex_sdk/prepare_inputs.py +++ b/pipelex_sdk/prepare_inputs.py @@ -55,17 +55,15 @@ _HTTP_URL_RE = re.compile(r"^https?://", re.IGNORECASE) # How `/v1/pipe-io` says it refused the pipe selection, which `_fetch_signature` turns into an -# `InputPreparationError`: a `422` whose `error_type` is one of the engine's entry-lookup errors. -# `EntryPipeNotFoundError` is an unknown `pipe_ref`, or no `pipe_ref` and a method declaring no -# entry pipe; `EntryPipeAmbiguousError` is a code matching pipes in several domains, or several -# `main_pipe` declarations. Every other `422` — a malformed body, a `method_ref` that does not -# parse or fetch, a stored method with no source — is not a selection and stays the -# `ApiResponseError` it is. The names are the runner's exception classes; they live here alone, -# so a rename upstream is a one-line edit. +# `InputPreparationError`: a `422` whose `error_type` is one of the engine's entry-lookup errors +# (pipelex-api >= 0.33.1). `EntryPipeNotFoundError` is an unknown `pipe_ref`, a manifest +# `main_pipe` the closure lacks, or no `pipe_ref` and no `main_pipe`; `EntryPipeAmbiguousError` is +# a bare code matching pipes in several domains, or no `pipe_ref` and several `main_pipe`s. Every +# other `422` — a malformed body, a `method_ref` that does not parse or fetch, a stored method with +# no source — is not a selection and stays the `ApiResponseError` it is. The names are the +# runner's exception classes; they live here alone, so a rename upstream is a one-line edit. _HTTP_UNPROCESSABLE_ENTITY = 422 _PIPE_SELECTION_ERROR_TYPES: frozenset[str] = frozenset({"EntryPipeNotFoundError", "EntryPipeAmbiguousError"}) -# The problem-document member a selection refusal may carry its candidate qualified refs in. -_CANDIDATES_MEMBER = "candidates" class PreparedInputs(BaseModel): @@ -327,24 +325,6 @@ def _is_pipe_selection_refusal(exc: ApiResponseError) -> bool: return exc.status == _HTTP_UNPROCESSABLE_ENTITY and exc.error_type in _PIPE_SELECTION_ERROR_TYPES -def _selection_refusal_reason(exc: ApiResponseError) -> str: - """The server's reason for a refused selection, with its candidates when the body lists them. - - The reason is the problem's `detail`. A candidate list the body carries as its own member is - appended unless the detail already names every candidate, as the engine's ambiguity message - does, so the refs are never repeated. Read defensively: a member that is not a list of - strings is ignored rather than trusted. - """ - reason = exc.server_message or exc.title or exc.response_body or exc.status_text - raw_candidates: object = exc.problem.get(_CANDIDATES_MEMBER) if exc.problem is not None else None - if not isinstance(raw_candidates, list): - return reason - candidates = [candidate for candidate in cast("list[object]", raw_candidates) if isinstance(candidate, str)] - if not candidates or all(candidate in reason for candidate in candidates): - return reason - return f"{reason} Candidates: {', '.join(candidates)}." - - async def _fetch_signature(client: _PrepareClient, *, request: PipeIORequest) -> PipeIOValidReport: """Ask `pipe_io` for the selected pipe's signature and hand back the valid report. @@ -361,16 +341,17 @@ async def _fetch_signature(client: _PrepareClient, *, request: PipeIORequest) -> A refused selection — a `422` whose `error_type` is an entry-lookup error (see `_PIPE_SELECTION_ERROR_TYPES`) — becomes an `InputPreparationError` carrying the server's - reason and its candidates, with the `ApiResponseError` kept as its `__cause__` for a caller - who needs the whole problem document. Every other non-2xx propagates as the - `ApiResponseError` it is. + `detail`, which names the candidates where there are any, with the `ApiResponseError` kept as + its `__cause__` for a caller who needs the whole problem document. Every other non-2xx + propagates as the `ApiResponseError` it is. """ try: response = await client.pipe_io(request) except ApiResponseError as exc: if not _is_pipe_selection_refusal(exc): raise - msg = f"Cannot prepare inputs: the pipe could not be selected — {_selection_refusal_reason(exc)}" + reason = exc.server_message or exc.title or exc.response_body or exc.status_text + msg = f"Cannot prepare inputs: the pipe could not be selected — {reason}" raise InputPreparationError(msg) from exc if isinstance(response, CrateInvalidReport): @@ -415,12 +396,12 @@ async def prepare_inputs( Raises: InputPreparationError: No selector or several; a selector or `pipe_ref` that is not a string; a bare `pipe_ref`; the closure did not resolve; the route refused the pipe - selection with the runner's entry-lookup `error_type` — an unknown `pipe_ref`, or - no `pipe_ref` and a method declaring no single entry pipe — carrying the server's - reason, with the `ApiResponseError` as its `__cause__`; or a value at a file - position is unusable. HTTP(S) URLs and - existing `pipelex-storage://` URIs pass through unchanged, and every failure is - raised BEFORE any run is created. + selection with the runner's entry-lookup `error_type` (pipelex-api >= 0.33.1) — an + unknown `pipe_ref`, or no `pipe_ref` and a method declaring no single entry pipe — + carrying the server's `detail`, with the `ApiResponseError` as its `__cause__`; or a + value at a file position is unusable. HTTP(S) URLs and existing + `pipelex-storage://` URIs pass through unchanged, and every failure is raised + BEFORE any run is created. ApiResponseError: Any other no-verdict condition from `/v1/pipe-io` — an unknown or foreign-org `method_id` or no package at a `method_ref` address (`404`), a `method_ref` that does not parse or fetch or a stored method with no source diff --git a/tests/e2e/test_pipe_io_e2e.py b/tests/e2e/test_pipe_io_e2e.py index b260f4c..0d84e22 100644 --- a/tests/e2e/test_pipe_io_e2e.py +++ b/tests/e2e/test_pipe_io_e2e.py @@ -89,8 +89,27 @@ template = "$topic" """ + +def _entry_domain_bundle(domain: str) -> str: + """One domain declaring `run` as its `main_pipe`: two of them make the entry pipe ambiguous.""" + return f"""domain = "{domain}" +main_pipe = "run" + +[pipe.run] +type = "PipeCompose" +description = "Echo a note" +inputs = {{ note = "Text" }} +output = "Text" +template = "$note" +""" + + _ENTRY_FILES = [MthdsFileItem(content=_ENTRY_BUNDLE, source="smoke_pipe_io.mthds")] _NO_ENTRY_FILES = [MthdsFileItem(content=_NO_ENTRY_BUNDLE, source="smoke_pipe_io_open.mthds")] +_SEVERAL_ENTRY_FILES = [ + MthdsFileItem(content=_entry_domain_bundle("smoke_pipe_io_alpha"), source="alpha.mthds"), + MthdsFileItem(content=_entry_domain_bundle("smoke_pipe_io_beta"), source="beta.mthds"), +] def _client() -> PipelexAPIClient: @@ -184,20 +203,23 @@ async def _prepare() -> PreparedInputs: assert prepared.uploads == [] @pytest.mark.parametrize( - ("files", "pipe_ref"), + ("files", "pipe_ref", "named"), [ - (_ENTRY_FILES, "smoke_pipe_io.absent"), - (_NO_ENTRY_FILES, None), + (_ENTRY_FILES, "smoke_pipe_io.absent", "smoke_pipe_io.absent"), + (_NO_ENTRY_FILES, None, "main_pipe"), + (_SEVERAL_ENTRY_FILES, None, "smoke_pipe_io_alpha.run, smoke_pipe_io_beta.run"), ], ) - def test_prepare_inputs_maps_a_refused_selection(self, files: list[MthdsFileItem], pipe_ref: str | None) -> None: - # Needs the runner to type the refusal with its entry-lookup `error_type`. + def test_prepare_inputs_maps_a_refused_selection(self, files: list[MthdsFileItem], pipe_ref: str | None, named: str) -> None: + # Needs the runner to type the refusal with its entry-lookup `error_type` (pipelex-api >= 0.33.1); + # the server's `detail` names what was refused, the candidates included. async def _prepare() -> PreparedInputs: async with _client() as client: return await client.prepare_inputs(files=files, pipe_ref=pipe_ref, inputs={}) - with pytest.raises(InputPreparationError, match="the pipe could not be selected"): + with pytest.raises(InputPreparationError, match="the pipe could not be selected") as exc_info: asyncio.run(_prepare()) + assert named in str(exc_info.value) # ── The hosted catalog selector ────────────────────────────────── diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py index 8ffdf7e..227be7c 100644 --- a/tests/unit/test_data.py +++ b/tests/unit/test_data.py @@ -143,27 +143,32 @@ class PipeIOBodies: ], "message": "1 validation error", } - #: The selection refusal the pipe-selector design asks for: the runner's entry-lookup error class - #: as `error_type`. The runner at `b4bafb8` still answers `error_type: ValidationError` here. + #: The selection refusals, as a local `pipelex-api` at `db9daa4` (the v0.33.1 fix) answered them on + #: 2026-09-30, less the per-request `request_id` and with the not-found `detail` shortened: the + #: runner's entry-lookup error class is the `error_type`, and the candidates, where there are any, + #: are named in `detail` alone. UNKNOWN_PIPE_REFUSAL: ClassVar[dict[str, Any]] = { "type": "https://docs.pipelex.com/latest/errors/entry-pipe-not-found-error/", "title": "Entry pipe not found", "status": 422, "detail": "Pipe 'smoke.absent' not found in the submitted closure.", + "instance": "/v1/pipe-io", "error_type": "EntryPipeNotFoundError", "error_domain": "input", - "retryable": False, - "instance": "/v1/pipe-io", + "user_action": { + "kind": "change_input", + "detail": "Check the pipe code for typos and make sure the bundle in scope for this operation declares it.", + }, } AMBIGUOUS_PIPE_REFUSAL: ClassVar[dict[str, Any]] = { "type": "https://docs.pipelex.com/latest/errors/entry-pipe-ambiguous-error/", "title": "Entry pipe ambiguous", "status": 422, "detail": "No `pipe_ref` was given and the closure declares several `main_pipe`s (alpha.run, beta.run) — name the pipe explicitly.", + "instance": "/v1/pipe-io", "error_type": "EntryPipeAmbiguousError", "error_domain": "input", - "retryable": False, - "instance": "/v1/pipe-io", + "user_action": {"kind": "change_input", "detail": "Send a `pipe_ref` naming one of the declared `main_pipe`s."}, } #: A `422` that is not a selection: the request-shape refusal the runner renders for a malformed body. REQUEST_SHAPE_REFUSAL: ClassVar[dict[str, Any]] = { diff --git a/tests/unit/test_prepare_inputs.py b/tests/unit/test_prepare_inputs.py index 95b3caf..b58acec 100644 --- a/tests/unit/test_prepare_inputs.py +++ b/tests/unit/test_prepare_inputs.py @@ -280,25 +280,6 @@ def test_a_refused_selection_is_an_input_preparation_error(self, body: dict[str, assert exc_info.value.__cause__ is refusal assert client.upload_calls == [] - def test_a_candidate_list_the_body_carries_is_named(self) -> None: - body = {**PipeIOBodies.UNKNOWN_PIPE_REFUSAL, "candidates": ["smoke.echo", "smoke.draft", 7]} - client = _FakePrepareClient(pipe_io_error=_api_error(422, body)) - - with pytest.raises(InputPreparationError) as exc_info: - asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref="smoke.absent", inputs={})) - - # A non-string member is dropped rather than trusted. - assert str(exc_info.value).endswith("not found in the submitted closure. Candidates: smoke.echo, smoke.draft.") - - def test_candidates_the_detail_already_names_are_not_repeated(self) -> None: - body = {**PipeIOBodies.AMBIGUOUS_PIPE_REFUSAL, "candidates": ["alpha.run", "beta.run"]} - client = _FakePrepareClient(pipe_io_error=_api_error(422, body)) - - with pytest.raises(InputPreparationError) as exc_info: - asyncio.run(prepare_inputs(client, files=_FILES, inputs={})) - - assert "Candidates:" not in str(exc_info.value) - @pytest.mark.parametrize( ("status", "body"), [ From 0ee22cc8bcfda6cb74359f8d49b97c0ab1c655bf Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Wed, 30 Sep 2026 11:58:49 +0200 Subject: [PATCH 4/6] Make the artifacts e2e bundle read its doc input pipelex now refuses a bundle whose template never reads a declared input (extraneous_input_variable), so prepare_inputs refused the round-trip bundle before any upload. An empty {% if doc %} block reads doc without rendering it, so the run still outputs the note alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EWwzmAGtZvzvQqeC1SvzJ1 --- tests/e2e/test_artifacts_e2e.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_artifacts_e2e.py b/tests/e2e/test_artifacts_e2e.py index 6cb05f8..3d5b368 100644 --- a/tests/e2e/test_artifacts_e2e.py +++ b/tests/e2e/test_artifacts_e2e.py @@ -42,7 +42,9 @@ reason="live leg: set PIPELEX_E2E_BASE_URL and PIPELEX_API_KEY to run it", ) -#: One domain, one main pipe, a Document input beside the Text it echoes — no inference. +#: One domain, one main pipe, a Document input beside the Text it echoes — no inference. The template +#: must read every declared input, or the bundle is refused (`extraneous_input_variable`); the empty +#: `{% if doc %}` block reads `doc` without rendering it, so the output stays the note alone. _PASS_THROUGH_BUNDLE = """domain = "smoke_artifacts" main_pipe = "echo_note" @@ -51,7 +53,7 @@ description = "Echo the note beside a document, with no inference" inputs = { doc = "Document", note = "Text" } output = "Text" -template = "$note" +template = "{% if doc %}{% endif %}$note" """ #: A minimal PDF with a nonce of its own, so a swapped file could not pass. Nothing in the run reads it. From d3f286f225d80a98b81ecf828041111255b35873 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Wed, 30 Sep 2026 12:04:57 +0200 Subject: [PATCH 5/6] Refuse a dependency package's pipe_ref before any request, as @pipelex/sdk does prepare_inputs now refuses an alias->domain.pipe_code ref before the bare check, with normalizePipeRef's wording: the alias names a dependency package's pipe, and the crate routes load no address-based dependency, so preparation covers the method's own pipes. The run routes take such a ref; the asymmetry is deliberate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EWwzmAGtZvzvQqeC1SvzJ1 --- CHANGELOG.md | 2 +- docs/input-preparation.md | 2 +- pipelex_sdk/prepare_inputs.py | 34 ++++++++++++++++++++++--------- tests/unit/test_prepare_inputs.py | 15 ++++++++++++++ 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b4bbd..8f00f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Changed -- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A selection the API refuses with the runner's entry-lookup `error_type` (`pipelex-api` v0.33.1 and later) raises `InputPreparationError` with the server's reason. The route runs no dry run, so a method whose dry run fails still prepares, but it refuses an address-based cross-package dependency that `validate` loaded, so a closure carrying one no longer prepares. +- **`prepare_inputs` reads its signature from `POST /v1/pipe-io`, which selects the pipe (Breaking)**: it needs an API that serves the route and raises `ApiResponseError` against one that does not. The pipe is the server's choice — your `pipe_ref`, else a package manifest's `main_pipe`, else the closure's single `main_pipe` declaration — so a method declaring no entry pipe or several now needs `pipe_ref` even when it has a single pipe, and a package naming its entry pipe in its manifest alone no longer does. A `pipe_ref` naming a dependency package's pipe (`alias->domain.pipe_code`) is refused before any request, as a bare one already was. A selection the API refuses with the runner's entry-lookup `error_type` (`pipelex-api` v0.33.1 and later) raises `InputPreparationError` with the server's reason. The route runs no dry run, so a method whose dry run fails still prepares, but it refuses an address-based cross-package dependency that `validate` loaded, so a closure carrying one no longer prepares. ## [v0.14.0] - 2026-09-27 diff --git a/docs/input-preparation.md b/docs/input-preparation.md index e3ea53c..ef0db52 100644 --- a/docs/input-preparation.md +++ b/docs/input-preparation.md @@ -84,7 +84,7 @@ A `method_ref` makes the server clone a repository first, so the call gets the c The route selects the pipe, and the helper keeps no selection chain of its own: -1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`. A bare code or a non-string is refused with an `InputPreparationError` before any request — the runner would still resolve a bare code across domains today, and search is a run-route affordance this helper does not grow. A qualified ref the method does not declare is refused by the route. +1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`. A bare code or a non-string is refused with an `InputPreparationError` before any request — the runner would still resolve a bare code across domains today, and search is a run-route affordance this helper does not grow. An `alias->domain.pipe_code` ref is refused the same way, since the alias names a dependency package's pipe and preparation covers the method's own pipes: the route loads no address-based dependency. The run routes take such a ref, so the asymmetry is deliberate, and `@pipelex/sdk` refuses it with the same wording. A qualified ref the method does not declare is refused by the route. 2. **A fetched package manifest's `main_pipe`**, for a `method_ref`: a package that names its entry pipe in `METHODS.toml` alone needs no `pipe_ref`. 3. **The closure's own `main_pipe` declaration**, when exactly one domain declares one. diff --git a/pipelex_sdk/prepare_inputs.py b/pipelex_sdk/prepare_inputs.py index a9e2322..7b8e772 100644 --- a/pipelex_sdk/prepare_inputs.py +++ b/pipelex_sdk/prepare_inputs.py @@ -305,16 +305,28 @@ def _resolve_selector( def _checked_pipe_ref(pipe_ref: object) -> str | None: - """The caller's `pipe_ref`, normalized — `None` when absent, refused when bare or not a string. - - Qualified-only is this helper's contract: the descriptor is keyed by qualified refs, and a - searched `pipe_code` is a run-route affordance preparation does not grow. The route would - still resolve a bare code across domains today — the pipe-selector rule that refuses one - server-side has not reached the runner's shared selection yet — so the refusal stays here, - raised before any request. + """The caller's `pipe_ref`, normalized — `None` when absent, refused when it names a dependency + package's pipe, when bare, or when not a string. Both refusals are raised before any request, in + the order `@pipelex/sdk`'s `normalizePipeRef` checks them, with the same wording. + + - An `alias->domain.pipe_code` ref is refused because the alias names a dependency package's + pipe, and preparation covers the method's own pipes: the crate routes do not load an + address-based dependency at all. The run route takes such a ref; preparation refuses it, and + that asymmetry is deliberate. + - A bare `pipe_code` is refused because a request names a pipe by its qualified ref. The route + will refuse it too once the runner's shared selection enforces that rule; until then it would + resolve a bare code across domains, and preparation does not lean on that fallback. """ requested = _caller_selector(pipe_ref, argument="pipe_ref") - if requested is not None and "." not in requested: + if requested is None: + return None + if "->" in requested: + msg = ( + f'Cannot prepare inputs: `pipe_ref` "{requested}" names a dependency package\'s pipe. ' + "Preparation covers the method's own pipes: name one as `domain.pipe_code`." + ) + raise InputPreparationError(msg) + if "." not in requested: msg = f'Cannot prepare inputs: `pipe_ref` must be qualified (`domain.pipe_code`), got the bare "{requested}".' raise InputPreparationError(msg) return requested @@ -384,7 +396,8 @@ async def prepare_inputs( selects the method's entry pipe — see "Pipe selection" in `docs/input-preparation.md`. A bare `pipe_code` is refused before any request: the descriptor is keyed by qualified refs, and search is a run-route affordance - this helper deliberately does not grow. + this helper deliberately does not grow. So is an `alias->domain.pipe_code` ref, + which names a dependency package's pipe rather than one of the method's own. inputs: The caller's inputs (variable name → value), compact or explicit-envelope per input. @@ -395,7 +408,8 @@ async def prepare_inputs( Raises: InputPreparationError: No selector or several; a selector or `pipe_ref` that is not a - string; a bare `pipe_ref`; the closure did not resolve; the route refused the pipe + string; a bare `pipe_ref`, or one naming a dependency package's pipe + (`alias->domain.pipe_code`); the closure did not resolve; the route refused the pipe selection with the runner's entry-lookup `error_type` (pipelex-api >= 0.33.1) — an unknown `pipe_ref`, or no `pipe_ref` and a method declaring no single entry pipe — carrying the server's `detail`, with the `ApiResponseError` as its `__cause__`; or a diff --git a/tests/unit/test_prepare_inputs.py b/tests/unit/test_prepare_inputs.py index b58acec..5d6a78a 100644 --- a/tests/unit/test_prepare_inputs.py +++ b/tests/unit/test_prepare_inputs.py @@ -258,6 +258,21 @@ def test_a_bare_pipe_ref_is_refused_before_any_request(self) -> None: assert str(exc_info.value) == 'Cannot prepare inputs: `pipe_ref` must be qualified (`domain.pipe_code`), got the bare "main".' assert client.pipe_io_calls == [] + @pytest.mark.parametrize("pipe_ref", ["deps->legal.summarize", "deps->summarize"]) + def test_a_dependency_package_pipe_ref_is_refused_before_any_request(self, pipe_ref: str) -> None: + # The alias names a dependency package's pipe, and the route loads no address-based + # dependency. Checked before the bare rule, so `deps->summarize` is named for what it is. + client = _image_client() + + with pytest.raises(InputPreparationError) as exc_info: + asyncio.run(prepare_inputs(client, files=_FILES, pipe_ref=pipe_ref, inputs={})) + + assert str(exc_info.value) == ( + f'Cannot prepare inputs: `pipe_ref` "{pipe_ref}" names a dependency package\'s pipe. ' + "Preparation covers the method's own pipes: name one as `domain.pipe_code`." + ) + assert client.pipe_io_calls == [] + @pytest.mark.parametrize( ("body", "detail"), [ From b1e35cd10357af5df9ea337bd5fa3fbf363c34a9 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Wed, 30 Sep 2026 13:14:35 +0200 Subject: [PATCH 6/6] Run the pipe-io e2e on a stored method, and type the refusal in the docs The hosted method_id case no longer creates a catalog method: it reads one already stored in the key's organization, named by PIPELEX_E2E_METHOD_ID, describes it under all_pipes with include_files, and prepares inputs by id with an upload at each file position. The method_ref case now runs under all_pipes and include_files too, and each refused-selection case checks the typed 422 relayed through the hosted proxy: the entry-lookup error_type, error_domain input, and the refused ref or the candidates in detail. The docstrings of PipeIORequest and pipe_io no longer call a refused selection a request-shape 422. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EWwzmAGtZvzvQqeC1SvzJ1 --- pipelex_sdk/client.py | 6 +- pipelex_sdk/crate_models.py | 9 ++- tests/e2e/test_pipe_io_e2e.py | 99 +++++++++++++++++++++----------- tests/unit/test_pipe_io_route.py | 5 +- 4 files changed, 77 insertions(+), 42 deletions(-) diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index bbbfd12..785837b 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -1306,8 +1306,10 @@ async def pipe_io(self, request: PipeIORequest) -> PipeIOResponse: Returns a 200 verdict: branch on `is_valid` before reading the arm. A no-verdict condition raises `ApiResponseError`: a refused selection (an unknown ref, or no - `pipe_ref` and a chain that finds no entry pipe or several, without `all_pipes`) and a - malformed request are `422`s; the `method_ref` fetch failures and the `method_id` + `pipe_ref` and a chain that finds no entry pipe or several, without `all_pipes`) is a + `422` whose `error_type` is `EntryPipeNotFoundError` or `EntryPipeAmbiguousError` + (pipelex-api >= 0.33.1); a malformed request is a request-shape `422`; the + `method_ref` fetch failures and the `method_id` resolution failures are those of `resolve`; an artifact the server cannot derive is a `500`. """ diff --git a/pipelex_sdk/crate_models.py b/pipelex_sdk/crate_models.py index 40dd9f4..3df9784 100644 --- a/pipelex_sdk/crate_models.py +++ b/pipelex_sdk/crate_models.py @@ -248,9 +248,12 @@ class PipeIORequest(CrateToolingRequest): `pipe_ref` names the pipe to describe by its qualified ref (`domain.pipe_code`) and is sent as given. Omitted, the server's selection chain decides: a fetched package manifest's - `main_pipe`, else the closure's single `main_pipe` declaration — and a chain that finds none, - or several, is a request-shape `422` unless `all_pipes` is set. The server resolves a bare - ref across domains today; `prepare_inputs` refuses one before sending it. + `main_pipe`, else the closure's single `main_pipe` declaration. A refused selection is a `422` + carrying the runner's entry-lookup `error_type` (pipelex-api >= 0.33.1): `EntryPipeNotFoundError` + for an unknown ref or a chain that finds no entry pipe, `EntryPipeAmbiguousError` for an + ambiguous bare code or a chain that finds several; under `all_pipes` a chain that finds none or + several is not refused. The server resolves a bare ref across domains today; `prepare_inputs` + refuses one before sending it. `all_pipes` describes every pipe the closure loads instead of the selected one, and never refuses for want of an entry pipe. `include_files` echoes the resolved closure's `.mthds` diff --git a/tests/e2e/test_pipe_io_e2e.py b/tests/e2e/test_pipe_io_e2e.py index 0d84e22..0d48299 100644 --- a/tests/e2e/test_pipe_io_e2e.py +++ b/tests/e2e/test_pipe_io_e2e.py @@ -4,14 +4,14 @@ hosted platform: PIPELEX_E2E_BASE_URL=http://127.0.0.1:8082 make e2e-test - PIPELEX_E2E_BASE_URL=https://api-dev.pipelex.com PIPELEX_API_KEY=plx_sk_… make e2e-test + PIPELEX_E2E_BASE_URL=https://api-dev.pipelex.com PIPELEX_API_KEY=plx_sk_… PIPELEX_E2E_METHOD_ID=mt_… make e2e-test The whole module skips when `PIPELEX_E2E_BASE_URL` is unset, so `make agent-test`, which does not collect this directory at all, never reaches it. The inline `files` and `method_ref` cases need only -the runner. The hosted `method_id` case needs the platform's catalog, and skips unless -`PIPELEX_API_KEY` is set: a bare runner answers without a key, and has no catalog to resolve an id -against. No case uploads a file, since a bare runner serves no `/v1/upload`; the upload leg rides -`test_artifacts_e2e.py` on the platform. +the runner. The hosted `method_id` case needs the platform: it skips unless `PIPELEX_API_KEY` and +`PIPELEX_E2E_METHOD_ID` are both set, the second naming a method already stored in that key's +organization whose entry pipe takes a Document or an Image. It creates nothing in the catalog, and it +is the one case here that uploads, since a bare runner serves no `/v1/upload`. What the unit suite cannot prove: that the answer the models parse, the selection the route makes, and the refusal `prepare_inputs` maps are the ones a real server produces. @@ -22,15 +22,14 @@ import asyncio import os import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest -from mthds.protocol.input_form import DocumentField, ObjectField +from mthds.protocol.input_form import DocumentField, ImageField, ObjectField from pipelex_sdk.client import PipelexAPIClient from pipelex_sdk.crate_models import CrateInvalidReport, MthdsFileItem, PipeIORequest, PipeIOValidReport -from pipelex_sdk.errors import InputPreparationError -from pipelex_sdk.product_models import MethodWriteInput +from pipelex_sdk.errors import ApiResponseError, InputPreparationError if TYPE_CHECKING: from pipelex_sdk.crate_models import PipeIOResponse @@ -38,6 +37,7 @@ _BASE_URL = os.environ.get("PIPELEX_E2E_BASE_URL", "") _API_KEY = os.environ.get("PIPELEX_API_KEY", "") +_METHOD_ID = os.environ.get("PIPELEX_E2E_METHOD_ID", "") pytestmark = pytest.mark.skipif(not _BASE_URL, reason="live leg: set PIPELEX_E2E_BASE_URL to run it") @@ -45,6 +45,14 @@ _METHOD_REF = "github.com/Pipelex/methods/documents@v0.1.0" _DOCUMENT_URL = "https://example.com/brief.pdf" +#: A minimal PDF with a nonce of its own, uploaded by the hosted `method_id` case. Nothing reads it. +_PDF_BYTES = ( + b"%PDF-1.4\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"%% nonce " + str(time.time()).encode("ascii") + b"\ntrailer<>\n%%EOF\n" +) + #: One domain declaring its entry pipe: a Document, a Text, and a structured input with an optional #: nested Image — no inference, and every declared input read by the template. _ENTRY_BUNDLE = '''domain = "smoke_pipe_io" @@ -161,13 +169,16 @@ def test_an_invalid_closure_is_the_crate_verdict(self) -> None: assert isinstance(report, CrateInvalidReport) assert report.validation_errors - def test_a_method_ref_selects_the_manifest_entry_pipe(self) -> None: - report = _pipe_io(PipeIORequest(method_ref=_METHOD_REF, include_files=True)) + def test_a_method_ref_describes_every_pipe_and_selects_the_manifest_entry_pipe(self) -> None: + report = _pipe_io(PipeIORequest(method_ref=_METHOD_REF, all_pipes=True, include_files=True)) assert isinstance(report, PipeIOValidReport) + # Under `all_pipes` with no `pipe_ref`, the chain's answer: the manifest's `main_pipe`. assert report.pipe_ref is not None assert report.pipe_ref == report.default_pipe_ref - assert report.files is not None + assert set(report.pipe_io_contracts) == set(report.input_form) == set(report.output_form) + assert report.pipe_ref in report.input_form + assert report.files assert all(item.source is not None and item.source.endswith(".mthds") for item in report.files) # ── prepare_inputs on the route ────────────────────────────────── @@ -203,16 +214,17 @@ async def _prepare() -> PreparedInputs: assert prepared.uploads == [] @pytest.mark.parametrize( - ("files", "pipe_ref", "named"), + ("files", "pipe_ref", "error_type", "named"), [ - (_ENTRY_FILES, "smoke_pipe_io.absent", "smoke_pipe_io.absent"), - (_NO_ENTRY_FILES, None, "main_pipe"), - (_SEVERAL_ENTRY_FILES, None, "smoke_pipe_io_alpha.run, smoke_pipe_io_beta.run"), + (_ENTRY_FILES, "smoke_pipe_io.absent", "EntryPipeNotFoundError", "smoke_pipe_io.absent"), + (_NO_ENTRY_FILES, None, "EntryPipeNotFoundError", "main_pipe"), + (_SEVERAL_ENTRY_FILES, None, "EntryPipeAmbiguousError", "smoke_pipe_io_alpha.run, smoke_pipe_io_beta.run"), ], ) - def test_prepare_inputs_maps_a_refused_selection(self, files: list[MthdsFileItem], pipe_ref: str | None, named: str) -> None: - # Needs the runner to type the refusal with its entry-lookup `error_type` (pipelex-api >= 0.33.1); - # the server's `detail` names what was refused, the candidates included. + def test_prepare_inputs_maps_a_refused_selection(self, files: list[MthdsFileItem], pipe_ref: str | None, error_type: str, named: str) -> None: + # Needs the runner to type the refusal with its entry-lookup `error_type` (pipelex-api >= 0.33.1), + # and the hosted proxy to relay it unchanged. The server's `detail` names what was refused: the + # unknown ref, the missing `main_pipe`, or the candidates when several are declared. async def _prepare() -> PreparedInputs: async with _client() as client: return await client.prepare_inputs(files=files, pipe_ref=pipe_ref, inputs={}) @@ -220,25 +232,42 @@ async def _prepare() -> PreparedInputs: with pytest.raises(InputPreparationError, match="the pipe could not be selected") as exc_info: asyncio.run(_prepare()) assert named in str(exc_info.value) + refusal = exc_info.value.__cause__ + assert isinstance(refusal, ApiResponseError) + assert refusal.status == 422 + assert refusal.error_type == error_type + assert refusal.error_domain == "input" + assert refusal.server_message is not None + assert named in refusal.server_message # ── The hosted catalog selector ────────────────────────────────── - @pytest.mark.skipif(not _API_KEY, reason="hosted leg: a stored method needs the platform catalog and PIPELEX_API_KEY") - def test_a_method_id_resolves_through_the_catalog(self) -> None: - async def _by_id() -> tuple[PipeIOResponse, PreparedInputs]: + @pytest.mark.skipif( + not _API_KEY or not _METHOD_ID, + reason="hosted leg: set PIPELEX_API_KEY and PIPELEX_E2E_METHOD_ID, a method stored in that key's organization", + ) + def test_a_stored_method_id_resolves_through_the_catalog(self) -> None: + async def _by_id() -> tuple[PipeIOResponse, dict[str, Any], PreparedInputs]: async with _client() as client: - method = await client.create_method(MethodWriteInput(name=f"sdk-python-e2e-pipe-io-{time.time_ns()}", mthds=_ENTRY_BUNDLE)) - try: - report = await client.pipe_io(PipeIORequest(method_id=method.method_id, include_files=True)) - prepared = await client.prepare_inputs(method_id=method.method_id, inputs={"doc": _DOCUMENT_URL, "note": "hi"}) - finally: - await client.delete_method(method.method_id) - return report, prepared - - report, prepared = asyncio.run(_by_id()) + report = await client.pipe_io(PipeIORequest(method_id=_METHOD_ID, all_pipes=True, include_files=True)) + assert isinstance(report, PipeIOValidReport) + assert report.pipe_ref is not None, "the stored method must declare an entry pipe" + file_fields = [field.name for field in report.input_form[report.pipe_ref].fields if isinstance(field, (DocumentField, ImageField))] + assert file_fields, "the stored method's entry pipe must take a Document or an Image" + # One byte string at every file position: preparation uploads it once and rewrites each. + inputs: dict[str, Any] = dict.fromkeys(file_fields, _PDF_BYTES) + prepared = await client.prepare_inputs(method_id=_METHOD_ID, inputs=inputs) + return report, inputs, prepared + + report, inputs, prepared = asyncio.run(_by_id()) assert isinstance(report, PipeIOValidReport) - assert report.pipe_ref == "smoke_pipe_io.echo" - assert report.files is not None - assert [item.content for item in report.files] == [_ENTRY_BUNDLE] - assert prepared.inputs == {"doc": {"url": _DOCUMENT_URL}, "note": "hi"} + assert report.pipe_ref == report.default_pipe_ref + assert set(report.pipe_io_contracts) == set(report.input_form) == set(report.output_form) + # The stored files come back under their stored names. + assert report.files + assert all(item.source for item in report.files) + assert len(prepared.uploads) == 1 + uploaded = prepared.uploads[0].uri + assert uploaded.startswith("pipelex-storage://") + assert prepared.inputs == {name: {"url": uploaded} for name in inputs} diff --git a/tests/unit/test_pipe_io_route.py b/tests/unit/test_pipe_io_route.py index 1780ca8..ac97e6f 100644 --- a/tests/unit/test_pipe_io_route.py +++ b/tests/unit/test_pipe_io_route.py @@ -163,8 +163,9 @@ def test_an_invalid_closure_is_a_200_verdict(self, mocker: MockerFixture) -> Non def test_a_no_verdict_answer_raises_api_response_error( self, mocker: MockerFixture, status: int, body: dict[str, object], error_type: str | None ) -> None: - """A refused selection is a request-shape `422`, never an `is_valid: false` verdict; the route - itself does not translate it, so the runner's `error_type` stays readable on the error. + """A refused selection is a `422` typed by the runner's entry-lookup `error_type`, never an + `is_valid: false` verdict; the route itself does not translate it, so the `error_type` stays + readable on the error and tells it apart from a request-shape `422`. """ client = self._client() self._mock_send(mocker, client, _response(status, json_body=body))