Skip to content

Commit dc09d5d

Browse files
authored
Merge pull request #58 from Pipelex/release/v0.11.0
Release v0.11.0: read output via results.main_stuff
2 parents 8a2ea07 + 325511a commit dc09d5d

21 files changed

Lines changed: 758 additions & 189 deletions

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Pipelex API endpoint and credentials.
22
# Sign up for an API key at https://go.pipelex.com/waitlist
3-
PIPELEX_API_URL=https://api.pipelex.com
3+
PIPELEX_BASE_URL=https://api.pipelex.com
44
PIPELEX_API_KEY=
55

66
# Self-hosted (open-source) runner instead of the hosted API:
7-
# PIPELEX_API_URL=http://127.0.0.1:8081
7+
# PIPELEX_BASE_URL=http://127.0.0.1:8081
88
# PIPELEX_API_KEY=

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
## [v0.11.0] - 2026-07-05
6+
7+
- **Fixed:** the demo bundle's list fields (`people`/`orgs`/`dates`) now declare `item_type = "text"` so the output is typed as `list[str]`, matching the `ExtractedEntities` model. Without it the runtime built the fields as `List[Any]`.
8+
- **Read a run's output with `results.main_stuff`.** Bumped to `pipelex-sdk` 0.3.0, which resolves the main output for you on both execution modes: `execute` returns a `PipelexExecuteResult` and the durable path a `RunResults`, and both expose a resolved `.main_stuff`. The starter's whole output-extraction module (`my_project/run_output.py``find_main_content` shape-guessing + the `to_run_results` adapter) is gone; the CLI and the narrower read `results.main_stuff` directly, and the blocking `execute` result is adapted onto `RunResults` inline in the runner. A completed run that delivers no main stuff raises the SDK's `MissingMainStuffError` instead of yielding `None`.
9+
- **Breaking:** renamed the env var `PIPELEX_API_URL` to `PIPELEX_BASE_URL` for consistency with the SDK's `base_url` naming. There is no read alias — update your `.env` / environment.
10+
- **Fixed:** rewrote the README and `CLAUDE.md` around the actual `my-project` CLI (the `extract-entities` command, the durable/blocking execution modes, and the `runs status|result|wait` lifecycle). They still described the removed `hello_world` module, `start_and_wait` usage, and the `find_main_content` normalizer, so the quick start's first command errored out for a fresh user.
11+
- **Repository:** removed internal-only planning docs (`TODOS.md`, `wip/`) that must not ship in a "Use this template" repo.
12+
313
## [v0.10.0] - 2026-07-01
414

515
- **Breaking:** run methods through the hosted Pipelex API instead of the local `pipelex` runtime. The `pipelex` package (and its `[tool.uv.sources]` git pin) is dropped; the starter now depends on `pipelex-sdk` (`PipelexAPIClient`) and `python-dotenv`.

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,17 @@ Run specific tests (local only): `make tp TEST=test_function_name`
3434

3535
This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`PipelexAPIClient`) — it does **not** run Pipelex as a local library. The `.mthds` bundle is read from disk and sent to the API as content (`mthds_contents`); the API runs the method and returns the output.
3636

37-
- Credentials/endpoint come from `PIPELEX_API_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
38-
- `my_project/hello_world.py` uses `client.start_and_wait(...)` — the durable start-and-poll path (survives the hosted gateway's ~30s cap, self-heals to blocking `execute` on a bare runner).
39-
- Output is loosely-typed JSON: hosted runs carry `main_stuff`; the bare-runner fallback carries `pipe_output`. `find_main_content()` normalizes both.
37+
- Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
38+
- The `my-project` CLI (`my_project/cli.py`) is a Typer app; `my_project/runner.py` dispatches each run by execution mode — `blocking` (`client.execute`), durable attended (`client.start` + `client.wait_for_result`), and durable detached (`client.start` only, resumed via `my-project runs status|result|wait <id>`). It branches on mode explicitly rather than using the SDK's `start_and_wait` self-healing one-liner, because teaching the mode difference is the point.
39+
- The SDK resolves the main output on both modes: `client.execute` returns a `PipelexExecuteResult` and the durable path a `RunResults`, both exposing a resolved `.main_stuff` (a completed run with no main stuff raises `MissingMainStuffError`). Per-example narrowing lives in `my_project/examples/``extract_entities.parse()` validates `results.main_stuff` into a typed `ExtractedEntities` model. SDK errors are mapped to CLI-facing messages + hints in `my_project/errors.py`.
4040

4141
## Project Structure
4242

4343
- Package: `my_project/` (Python 3.10+, target 3.11)
44-
- Tests: `tests/` (integration = offline boot/bundle checks + API `validate`; e2e = full run via the API)
44+
- Tests: `tests/` (unit = offline CLI/example/error-mapping tests; integration = offline boot/bundle checks + API `validate`; e2e = full run via the API)
4545
- Dependency manager: uv (>=0.7.2)
4646
- Pipelex dependency: `pipelex-sdk` package from PyPI (the API client — see pyproject.toml). The `pipelex` runtime is **not** a dependency.
47-
- `.mthds` files: Pipelex method definition files in `my_project/`
47+
- `.mthds` files: Pipelex method definition files in `my_project/methods/<name>/main.mthds`
4848

4949
## Test markers
5050

README.md

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
A minimal Python CLI starter that calls the [Pipelex](https://pipelex.com) API via the [`pipelex-sdk`](https://pypi.org/project/pipelex-sdk/) SDK to run AI methods (`.mthds` bundles) — no local Pipelex runtime required.
66

7-
It ships one demo pipeline, `my_project/hello_world.mthds`, which asks an LLM to write a haiku about "Hello World" and prints it.
7+
It ships one demo method, `extract-entities` (`my_project/methods/extract-entities/main.mthds`), exposed through a `my-project` CLI. Given a piece of text it asks an LLM to pull out the people, organizations, and dates it mentions, and prints them as JSON.
88

99
### Use this template
1010

@@ -21,48 +21,61 @@ Once you've created your repository from it, clone it and follow the instruction
2121

2222
Access to a **Pipelex API** server. You have two options:
2323

24-
- **Hosted** — currently in private beta. Join the waitlist at [go.pipelex.com/waitlist](https://go.pipelex.com/waitlist). Once you have access, get an API key at [app.pipelex.com](https://app.pipelex.com) and point `PIPELEX_API_URL` at `https://api.pipelex.com` (the default).
25-
- **Self-hosted** — the Pipelex API is open source at [github.com/Pipelex/pipelex-api](https://github.com/Pipelex/pipelex-api). Run it locally or on your own infra and point `PIPELEX_API_URL` at your instance (e.g. `http://127.0.0.1:8081`).
24+
- **Hosted** — currently in private beta. Join the waitlist at [go.pipelex.com/waitlist](https://go.pipelex.com/waitlist). Once you have access, get an API key at [app.pipelex.com](https://app.pipelex.com) and point `PIPELEX_BASE_URL` at `https://api.pipelex.com` (the default).
25+
- **Self-hosted** — the Pipelex API is open source at [github.com/Pipelex/pipelex-api](https://github.com/Pipelex/pipelex-api). Run it locally or on your own infra and point `PIPELEX_BASE_URL` at your instance (e.g. `http://127.0.0.1:8081`).
2626

2727
## Quick start
2828

2929
```bash
3030
cp .env.example .env
31-
# edit .env and set PIPELEX_API_KEY (and PIPELEX_API_URL if self-hosting)
31+
# edit .env and set PIPELEX_API_KEY (and PIPELEX_BASE_URL if self-hosting)
3232

3333
make install # create the venv and install deps with uv
34-
python -m my_project.hello_world # run the hello_world example against the API
34+
uv run my-project extract-entities "Alice from Acme met Bob on May 3rd, 2026."
3535
```
3636

37-
That prints the generated haiku.
37+
That prints the extracted people, organizations, and dates as JSON. (`uv run` finds the project's venv; activate it with `source .venv/bin/activate` if you'd rather drop the prefix and just call `my-project ...`.)
3838

3939
## Project structure
4040

4141
```
4242
my_project/
43-
hello_world.mthds # the method bundle: text → { text } haiku
44-
hello_world.py # the CLI entry point that runs the bundle via the SDK
43+
cli.py # the `my-project` Typer CLI (console-script entry point)
44+
runner.py # execution-mode dispatch: blocking / durable attended / detached
45+
errors.py # maps SDK errors to CLI messages + hints
46+
examples/
47+
extract_entities.py # the "copy me" unit: bundle path, output model, parse() narrower
48+
methods/
49+
extract-entities/main.mthds # the method bundle: text → { people, orgs, dates }
4550
tests/
46-
integration/ # offline boot/bundle checks + API validate (pipelex_api)
47-
e2e/ # full run against the API (inference)
48-
.env.example # PIPELEX_API_URL + PIPELEX_API_KEY
51+
unit/ # offline CLI / example / error-mapping tests
52+
integration/ # offline boot/bundle checks + API validate (pipelex_api)
53+
e2e/ # full run against the API (inference)
54+
.env.example # PIPELEX_BASE_URL + PIPELEX_API_KEY
4955
```
5056

5157
## How it works
5258

53-
`my_project/hello_world.py`:
59+
`my-project extract-entities "<text>"`:
5460

55-
1. Reads the `.mthds` bundle from disk (`BUNDLE_PATH`).
56-
2. Constructs a `PipelexAPIClient`, which reads `PIPELEX_API_URL` / `PIPELEX_API_KEY` from the environment.
57-
3. Calls `start_and_wait(pipe_code="hello_world", mthds_contents=[bundle])` — the durable start-and-poll path that survives the hosted gateway's ~30s synchronous cap and self-heals to a blocking `execute` against a bare self-hosted runner.
58-
4. Reads the main output's content (`{"text": ...}`) out of the loosely-typed result and prints it.
61+
1. Reads the `.mthds` bundle from disk (`extract_entities.BUNDLE_PATH`) and constructs a `PipelexAPIClient`, which picks up `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` from the environment.
62+
2. Runs the pipe against the API in one of **two execution modes** (`--mode`, env var `PIPELEX_EXECUTION_MODE`):
63+
- **durable** (default) — `client.start()` then poll the run to completion (`client.wait_for_result`). Survives the hosted gateway's ~30s synchronous cap, so long runs succeed; the run id is printed first, so a Ctrl-C leaves it executing server-side and you can resume with `my-project runs wait <id>`.
64+
- **blocking** — a single `client.execute()` call. Simpler, but behind the hosted gateway a run over ~30s is cut off and surfaces a clear timeout error pointing you at durable mode.
65+
3. Reads the resolved main output — the SDK exposes `results.main_stuff` on both modes — and the example's `parse()` narrower validates it into a typed `ExtractedEntities` model, printed as JSON.
5966

60-
The `.mthds` bundle is sent to the API as content (`mthds_contents`), so nothing about the method needs to live in the runtime — edit `hello_world.mthds` and re-run.
67+
The `.mthds` bundle is sent to the API as content (`mthds_contents`), so nothing about the method needs to live in the runtime — edit `methods/extract-entities/main.mthds` and re-run.
68+
69+
`my_project/runner.py` deliberately branches on the mode explicitly rather than calling the SDK's `start_and_wait()` self-healing one-liner (the production shortcut when you don't care which mode) — teaching the difference between the two paths is the point of this starter. Pass `--detach` (durable only) to start a run and return immediately, then pick it back up later with `my-project runs status|result|wait <id>`.
6170

6271
## Useful commands
6372

6473
```bash
65-
python -m my_project.hello_world # run the hello_world example
74+
uv run my-project extract-entities "Alice from Acme met Bob on May 3rd, 2026." # run the demo method
75+
uv run my-project extract-entities --file notes.txt # read the input text from a file
76+
uv run my-project extract-entities "" --mode blocking # single synchronous call (~30s cap on hosted)
77+
uv run my-project extract-entities "" --detach # start a durable run, print its id, return
78+
uv run my-project runs wait <run-id> # resume a detached run to completion (also: runs status / runs result)
6679
make validate # lint/validate the .mthds bundle with plxt (offline)
6780
make agent-check # fix-imports + format + lint + pyright + mypy
6881
make agent-test # run the offline test suite (silent on success)

my_project/cli.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""The `my-project` CLI — demo Pipelex methods behind a Typer app.
2+
3+
Commands stay thin: parse arguments, dispatch on execution mode via
4+
`my_project.runner`, narrow + render via the matching `my_project.examples`
5+
module. SDK errors are caught once per command (in `_run_cli`) and presented by
6+
`my_project.errors`; anything unexpected crashes loudly.
7+
"""
8+
9+
import asyncio
10+
from pathlib import Path
11+
from typing import Annotated, Any, Coroutine, TypeVar
12+
13+
import httpx
14+
import typer
15+
from dotenv import load_dotenv
16+
from mthds.protocol.exceptions import PipelineRequestError
17+
from pipelex_sdk.runs import RunResultCompleted, RunResultFailed, RunResultRunning, RunResults, RunResultState
18+
from rich.console import Console
19+
20+
from my_project.errors import present_error
21+
from my_project.examples import extract_entities as extract_entities_example
22+
from my_project.runner import (
23+
ExecutionMode,
24+
fetch_run_result,
25+
fetch_run_status,
26+
progress_console,
27+
run_blocking,
28+
run_durable_attended,
29+
start_detached,
30+
wait_for_run,
31+
)
32+
33+
ResultT = TypeVar("ResultT")
34+
35+
app = typer.Typer(no_args_is_help=True, help="Run the demo Pipelex methods through the Pipelex API.")
36+
runs_app = typer.Typer(no_args_is_help=True, help="Inspect and resume durable runs by id.")
37+
app.add_typer(runs_app, name="runs")
38+
39+
# Results go to stdout (pipeable); progress/status chatter goes to stderr (see runner.py).
40+
output_console = Console()
41+
42+
MODE_HELP = "How to execute the run: `durable` (start + poll, survives anything) or `blocking` (single call, ~30s cap on hosted)."
43+
DETACH_HELP = "Start the run and exit immediately; fetch it later with `my-project runs ...` (durable mode only)."
44+
45+
46+
@app.callback()
47+
def main() -> None:
48+
"""Load .env so PIPELEX_BASE_URL / PIPELEX_API_KEY are available."""
49+
load_dotenv()
50+
51+
52+
@app.command(name="extract-entities")
53+
def extract_entities(
54+
text: Annotated[str | None, typer.Argument(help="The text to extract entities from.")] = None,
55+
file: Annotated[Path | None, typer.Option("--file", help="Read the input text from a file instead of the argument.")] = None,
56+
mode: Annotated[ExecutionMode, typer.Option(envvar="PIPELEX_EXECUTION_MODE", help=MODE_HELP)] = ExecutionMode.DURABLE,
57+
detach: Annotated[bool, typer.Option("--detach", help=DETACH_HELP)] = False,
58+
) -> None:
59+
"""Extract people, organizations, and dates from a piece of text."""
60+
input_text = _read_text_input(text=text, file=file)
61+
bundle = extract_entities_example.BUNDLE_PATH.read_text()
62+
results = _dispatch(
63+
pipe_code=extract_entities_example.PIPE_CODE,
64+
bundle=bundle,
65+
inputs={"text": input_text},
66+
mode=mode,
67+
detach=detach,
68+
)
69+
if results is None:
70+
return
71+
# Narrow into the typed model (validates the concept's shape), then print it
72+
# as JSON — the same rendering `runs result` / `runs wait` give.
73+
entities = extract_entities_example.parse(results)
74+
output_console.print_json(data=entities.model_dump())
75+
76+
77+
@runs_app.command(name="status")
78+
def runs_status(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None:
79+
"""Show a run's coarse status without waiting."""
80+
run = _run_cli(fetch_run_status(run_id))
81+
pipe_part = f" (pipe: {run.pipe_code})" if run.pipe_code else ""
82+
output_console.print(f"{run.pipeline_run_id}: [bold]{run.status}[/bold]{pipe_part}")
83+
if run.degraded:
84+
output_console.print("[yellow]Status is degraded — last-known value, the status backend was unreachable; retry shortly.[/yellow]")
85+
86+
87+
@runs_app.command(name="result")
88+
def runs_result(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None:
89+
"""Fetch a run's result if it is finished (no waiting)."""
90+
state = _run_cli(fetch_run_result(run_id))
91+
_render_result_state(state)
92+
93+
94+
@runs_app.command(name="wait")
95+
def runs_wait(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None:
96+
"""Poll a run to completion, then print its raw result."""
97+
results = _run_cli(wait_for_run(run_id))
98+
_print_raw_results(results)
99+
100+
101+
def _read_text_input(*, text: str | None, file: Path | None) -> str:
102+
if text is not None and file is not None:
103+
msg = "Give the text either as an argument or via --file, not both."
104+
raise typer.BadParameter(msg)
105+
if file is not None:
106+
return file.read_text()
107+
if text is not None:
108+
return text
109+
msg = "Give the text to process as an argument, or point --file at a text file."
110+
raise typer.BadParameter(msg)
111+
112+
113+
def _dispatch(*, pipe_code: str, bundle: str, inputs: dict[str, Any], mode: ExecutionMode, detach: bool) -> RunResults | None:
114+
"""Run the pipe in the requested mode; returns None when detached (id already printed)."""
115+
if detach:
116+
match mode:
117+
case ExecutionMode.BLOCKING:
118+
msg = "--detach starts a durable run; it cannot be combined with --mode blocking."
119+
raise typer.BadParameter(msg)
120+
case ExecutionMode.DURABLE:
121+
pass
122+
run_id = _run_cli(start_detached(pipe_code=pipe_code, bundle=bundle, inputs=inputs))
123+
print(run_id)
124+
progress_console.print(f"Run started — fetch it later with: [bold]my-project runs wait {run_id}[/bold]")
125+
return None
126+
match mode:
127+
case ExecutionMode.BLOCKING:
128+
return _run_cli(run_blocking(pipe_code=pipe_code, bundle=bundle, inputs=inputs))
129+
case ExecutionMode.DURABLE:
130+
return _run_cli(run_durable_attended(pipe_code=pipe_code, bundle=bundle, inputs=inputs))
131+
132+
133+
def _run_cli(coro: Coroutine[Any, Any, ResultT]) -> ResultT:
134+
"""Await a runner coroutine, presenting SDK errors and Ctrl-C as clean exits."""
135+
try:
136+
return asyncio.run(coro)
137+
except (PipelineRequestError, httpx.HTTPStatusError) as exc:
138+
presentation = present_error(exc)
139+
progress_console.print(f"[red]Error:[/red] {presentation.message}")
140+
if presentation.hint:
141+
progress_console.print(f"[yellow]Hint:[/yellow] {presentation.hint}")
142+
raise typer.Exit(1) from exc
143+
except KeyboardInterrupt as exc:
144+
# The resume hint was already printed by the runner; the run keeps executing server-side.
145+
raise typer.Exit(130) from exc
146+
147+
148+
def _render_result_state(state: RunResultState) -> None:
149+
match state:
150+
case RunResultRunning():
151+
progress_console.print(
152+
f"Run {state.pipeline_run_id} is still running — wait for it with: [bold]my-project runs wait {state.pipeline_run_id}[/bold]"
153+
)
154+
case RunResultCompleted():
155+
_print_raw_results(state.result)
156+
case RunResultFailed():
157+
progress_console.print(f"[red]Run {state.pipeline_run_id} ended with status {state.status}: {state.message}[/red]")
158+
raise typer.Exit(1)
159+
160+
161+
def _print_raw_results(results: RunResults) -> None:
162+
"""Print the run's main content as JSON — generic, no per-example narrowing."""
163+
output_console.print_json(data=results.main_stuff)
164+
165+
166+
if __name__ == "__main__":
167+
app()

0 commit comments

Comments
 (0)