Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [Unreleased]

### Changed

- **`pipelex-sdk` 0.14.0 and `mthds` 0.17.0 are the floors, and `httpx` is no longer a dependency (Breaking)**: from `pipelex-sdk` 0.14.0 every route, `execute` and `start` among them, raises the typed `ApiResponseError` on a non-2xx answer, so each mode's `_run()` catches `PipelineRequestError` with no second arm for a raw `httpx.HTTPStatusError`, and nothing in the project parses one any more. Code copied from the starter that caught `httpx.HTTPStatusError` catches `ApiResponseError` and reads `exc.status` where it read `exc.response.status_code`.

### Fixed

- **A refused run says why, where and what to do**: a method the API refuses to run, met by `widget blocking …`, `widget attended …` or `widget detached …`, now prints the refusal's reason, the pipe or concept each of its validation errors names, the next step the server advises and whether running it again can succeed, the same lines a failed run prints, instead of the reason alone; a reason that spans lines hangs under its label. Any other refused request reads out its reason the same way, an authentication failure included, beside the API-key hint.
- **`make codegen` and `make add-method` tell a missing route from a 404 the route answered**: a 404 carrying the runner's error class, such as a `method_ref` whose package does not exist, is reported with the server's reason instead of sending you to check `PIPELEX_BASE_URL`, and any other refused request prints the server's next step under its reason.

## [v0.1.1] - 2026-09-27

### Changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi

- Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
- **The execution mode is the command group, not an option.** There are exactly three, each a self-contained Typer sub-package: `widget blocking …` (`client.execute` — one call, dies at the hosted ~30s cap), `widget attended …` (`client.start` + `client.wait_for_result` — durable, you wait), `widget detached …` (`client.start` only — durable, you collect it later with `widget detached status|result|wait <id>`). Attended and detached start the *same* durable run; the axis they name is who waits. There is no default mode and no `--mode` option: `widget/cli.py` is a thin assembler (`load_dotenv` callback + three `add_typer` calls in reading order) and nothing else.
- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget/<mode>/cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `widget/errors.py` (SDK error → message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for; a run that ended without a result is presented from its stored error report — `present_failed_run` and `report_lines` read out the reason, the next step and the retry advice, the same lines `widget detached status` and `result` print — and `print_error` escapes the server's text before Rich sees it), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** — the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes — a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method — are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`.
- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget/<mode>/cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `widget/errors.py` (SDK error → message + hint, hints naming the mode groups; every route of the SDK raises the typed `ApiResponseError` on a non-2xx answer, and `problem_lines` reads its **problem+json** document out — the reason, the pipe or concept each validation item names, the next step and the retry advice — while the hint branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for; a run that ended without a result is presented from its stored error report — `present_failed_run` and `report_lines` read out the reason, the next step and the retry advice, the same lines `widget detached status` and `result` print — and `print_error` escapes the server's text before Rich sees it), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** — the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes — a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method — are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`.
- **Full demo matrix, guarded.** All three demos exist in all three modes: `extract-entities` (text in), `summarize-pdf` (a *file* in), `generate-image` (prompt in). `generate-image` is the deliberate slow case that overruns the ~30s blocking cap — `widget blocking generate-image` is *expected to fail*, and that is the teaching moment for the durable modes. The near-duplication across mode files is the pedagogy (diff two mode files and only the lifecycle helper differs); `tests/unit/test_mode_symmetry.py` keeps it from drifting. `samples/sample-invoice.pdf` is shipped for `summarize-pdf`.
- The SDK resolves the main output on every result-producing path (`client.execute` returns a `PipelexExecuteResult`, the durable path a `RunResults`, both exposing a resolved `.main_stuff`, typed `Any`; a completed run with no main stuff raises `MissingMainStuffError`). So the result-producing lifecycle helpers (`execute_pipe`, `start_and_wait`, detached's `attend_run`) all return the SDK's `RunResults` — one object carrying the resolved output, what the run consumed and the references to the files it produced. The blocking mode reaches it through `pipelex_sdk.execute_result.results_from_execute`, the SDK's public lift (0.10.2), so no mode reads the runner's raw `pipe_output`. The blocking/attended demo commands narrow `results.main_stuff` inline — e.g. `ExtractedEntities.model_validate(results.main_stuff)` — into the generated model, then hand `results` to the cost report and the download. Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare, no cost — the run isn't done), and the run-id commands (`wait`/`result`) print the output generically **and** its produced files and cost report — no model narrowing, since at collection time the command doesn't know which method the run executed. There is no per-example wrapper layer.
- The modes spell out lifecycles the SDK could hide: `client.start_and_wait()` is a self-healing one-liner that picks the path for you (the production shortcut). The starter writes them out because teaching the difference is the point.
Expand Down
18 changes: 15 additions & 3 deletions docs/cli-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ All three have the same four-part shape, so they diff cleanly:
1. **Module docstring** — the mode's contract in a paragraph, plus its copy-paste contract.
2. **App + consoles** — its own `typer.Typer`, its own `Console()` (stdout, for results — pipeable) and `Console(stderr=True)` (stderr, for progress chatter).
3. **The lifecycle helper** — one public async function that *is* the mode (`detached` adds the run-id lifecycle helpers described below). It gets a public name because it is the featured code, and it is what the unit tests patch and the e2e tests call directly.
4. **The demo commands + a private `_run()`** — each command reads its input, reads its bundle, awaits the lifecycle helper through `_run()` (`asyncio.run` + the single `except (PipelineRequestError, httpx.HTTPStatusError)` that presents via `widget/errors.py`), narrows the result into its *generated* model, prints JSON to stdout, brings down any file the result references, and prints the run's cost report to stderr.
4. **The demo commands + a private `_run()`** — each command reads its input, reads its bundle, awaits the lifecycle helper through `_run()` (`asyncio.run` + the single `except PipelineRequestError` that presents via `widget/errors.py`), narrows the result into its *generated* model, prints JSON to stdout, brings down any file the result references, and prints the run's cost report to stderr.

The lifecycle helpers take the bundle as `mthds_contents: list[str]` — one string per `.mthds` file — and pass it straight to the SDK. The three demos are single-file methods, so each reads its `main.mthds` and wraps it as `mthds_contents=[bundle]`. A method dir may instead hold several `.mthds` files (a multi-file bundle split across pipes with `signature_for` cross-file declarations); read them all with `[p.read_text() for p in sorted((METHODS_DIR / "<name>").glob("*.mthds"))]` and hand that list to the helper unchanged. Concatenating the files into one string would be invalid TOML — the list is the interface for exactly this reason.

Expand All @@ -69,9 +69,21 @@ Three SDK capabilities show up in every result-producing path:

**stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) — `RUN_ID=$(widget detached generate-image "…")` just works.

**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building.
**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of the SDK's typed errors, a non-2xx answer from any route included), maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. One failure you might expect to be presented crashes that way too: a connection that fails on `execute` or `start`, from a mistyped `PIPELEX_BASE_URL` or a machine that is offline, reaches `_run()` as httpx's own `ConnectError`, because the SDK wraps a transport failure as `ApiUnreachableError` only on the routes that read a run. There `widget detached status` and its siblings present it with the `PIPELEX_BASE_URL` hint.

The protocol routes (`execute`/`start`/`runs/*`) surface a non-2xx as a raw `httpx.HTTPStatusError`, whose default string is useless (`Client error '400 Bad Request' for url …` + an MDN link). `widget/errors.py` instead reads the API's RFC 7807 **problem+json** body and shows the server's own `detail`, branching on the structured `error_type` (never the transport status) for the cases worth a hint — a `/start` against a synchronous-only runner (`StartRequiresAsyncOrchestration`) is presented with a hint pointing at `widget blocking`.
**A refused request says why, where and what to do.** Every route of the SDK — the protocol routes (`execute`, `start`, `runs/*`) as much as the product ones — raises the typed `ApiResponseError` on a non-2xx answer, with the API's RFC 9457 **problem+json** document parsed onto it: its `title` and `detail`, the runner's `error_type`, the `validation_errors` of a method it refused to run, the `user_action` it advises and whether the request is `retryable`. `problem_lines` reads that document out the way `report_lines` reads a failed run's report, so a method the API will not run reads like this whichever mode you met it with:

```text
Error: The API answered 422 Unprocessable Entity.
Reason: Validate bundle — Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found in the model deck

Did you mean: gpt-5.5, gpt-5.4, gpt-5.6-sol, gpt-5.4-pro, gpt-5.6-luna
Pipe: draft_pitch
Next step: Edit the bundle as each validation error says: apply its suggested fix where it has one, after confirming an unsafe one
Retry: running it again will fail the same way until the cause is fixed.
```

The reason is the problem's `title` and `detail`. Each validation item follows it, located by the pipe or concept it names; the runner writes a refusal's `detail` from its items, so an item whose message the reason already says is reduced to where it is rather than printed twice. The lines of a multi-line reason hang under its label. As under a failed run's report, the next step is the advice, and no hint competes with it. The branch that earns a hint goes on the structured `error_type`, never the wording: a `/start` against a synchronous-only runner (`StartRequiresAsyncOrchestration`) is presented with a hint pointing at `widget blocking`, which the server cannot know to say. The HTTP status decides only an authentication failure (`401`/`403`), which gets the API-key hint and still reads out its reason, because a `403` can be a key that was recognised but may not use the route.

**A failed run says why.** A durable run that ended without a result carries the error report the runner stored when it failed, which the SDK hands back typed as `RunErrorReport` on `RunFailedError.error` (out of `wait_for_result`, `start_and_wait` and an artifact download), on the failed arm of `get_run_result`, and on the status read's `RunRead.error`. `present_failed_run` presents all three the same way, and `report_lines` reads the report out as the lines a person reads:

Expand Down
12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ classifiers = [
# cost report). A transitive dependency is not a contract, so a release that dropped either would
# break a fresh install of a template every new project is cloned from. The `mthds` floor is the
# version `pipelex-sdk` pins exactly today, which is a floor and not a second pin: the SDK's exact
# pin always satisfies it, so bumping the SDK never has to be matched here.
# pin always satisfies it, so bumping the SDK never has to be matched here. `httpx` is not among
# them: since `pipelex-sdk` 0.14.0 every route raises the SDK's typed `ApiResponseError` on a
# non-2xx answer, so nothing here imports it, and it arrives only as the SDK's own transport.
dependencies = [
"httpx>=0.27.0",
"mthds>=0.16.0",
"pipelex-sdk>=0.13.0",
"mthds>=0.17.0",
"pipelex-sdk>=0.14.0",
"python-dotenv>=1.0.0",
"rich>=13.0.0",
"typer>=0.15.0",
Expand Down Expand Up @@ -54,7 +55,10 @@ widget = ["py.typed", "methods/*/*.mthds", "methods/*/method.json"]
"*" = ["codegen.lock"]

[project.optional-dependencies]
# `httpx` is here because the tests import it to fake the SDK's transport (`tests/conftest.py`), which
# `widget/` never does.
dev = [
"httpx>=0.27.0",
"mypy==1.19.1",
"pipelex-tools>=0.7.2",
"pyright>=1.1.411",
Expand Down
Loading
Loading