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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Pipelex API endpoint and credentials.
# Sign up for an API key at https://go.pipelex.com/waitlist
PIPELEX_API_URL=https://api.pipelex.com
PIPELEX_BASE_URL=https://api.pipelex.com
PIPELEX_API_KEY=

# Self-hosted (open-source) runner instead of the hosted API:
# PIPELEX_API_URL=http://127.0.0.1:8081
# PIPELEX_BASE_URL=http://127.0.0.1:8081
# PIPELEX_API_KEY=
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [Unreleased]

## [v0.11.0] - 2026-07-05

- **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]`.
- **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`.
- **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.
- **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.
- **Repository:** removed internal-only planning docs (`TODOS.md`, `wip/`) that must not ship in a "Use this template" repo.

## [v0.10.0] - 2026-07-01

- **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`.
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,17 @@ Run specific tests (local only): `make tp TEST=test_function_name`

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.

- Credentials/endpoint come from `PIPELEX_API_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
- `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).
- Output is loosely-typed JSON: hosted runs carry `main_stuff`; the bare-runner fallback carries `pipe_output`. `find_main_content()` normalizes both.
- Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
- 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.
- 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`.

## Project Structure

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

## Test markers

Expand Down
49 changes: 31 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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.

It ships one demo pipeline, `my_project/hello_world.mthds`, which asks an LLM to write a haiku about "Hello World" and prints it.
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.

### Use this template

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

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

- **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).
- **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`).
- **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).
- **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`).

## Quick start

```bash
cp .env.example .env
# edit .env and set PIPELEX_API_KEY (and PIPELEX_API_URL if self-hosting)
# edit .env and set PIPELEX_API_KEY (and PIPELEX_BASE_URL if self-hosting)

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

That prints the generated haiku.
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 ...`.)

## Project structure

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

## How it works

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

1. Reads the `.mthds` bundle from disk (`BUNDLE_PATH`).
2. Constructs a `PipelexAPIClient`, which reads `PIPELEX_API_URL` / `PIPELEX_API_KEY` from the environment.
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.
4. Reads the main output's content (`{"text": ...}`) out of the loosely-typed result and prints it.
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.
2. Runs the pipe against the API in one of **two execution modes** (`--mode`, env var `PIPELEX_EXECUTION_MODE`):
- **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>`.
- **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.
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.

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.
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.

`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>`.

## Useful commands

```bash
python -m my_project.hello_world # run the hello_world example
uv run my-project extract-entities "Alice from Acme met Bob on May 3rd, 2026." # run the demo method
uv run my-project extract-entities --file notes.txt # read the input text from a file
uv run my-project extract-entities "…" --mode blocking # single synchronous call (~30s cap on hosted)
uv run my-project extract-entities "…" --detach # start a durable run, print its id, return
uv run my-project runs wait <run-id> # resume a detached run to completion (also: runs status / runs result)
make validate # lint/validate the .mthds bundle with plxt (offline)
make agent-check # fix-imports + format + lint + pyright + mypy
make agent-test # run the offline test suite (silent on success)
Expand Down
167 changes: 167 additions & 0 deletions my_project/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""The `my-project` CLI — demo Pipelex methods behind a Typer app.

Commands stay thin: parse arguments, dispatch on execution mode via
`my_project.runner`, narrow + render via the matching `my_project.examples`
module. SDK errors are caught once per command (in `_run_cli`) and presented by
`my_project.errors`; anything unexpected crashes loudly.
"""

import asyncio
from pathlib import Path
from typing import Annotated, Any, Coroutine, TypeVar

import httpx
import typer
from dotenv import load_dotenv
from mthds.protocol.exceptions import PipelineRequestError
from pipelex_sdk.runs import RunResultCompleted, RunResultFailed, RunResultRunning, RunResults, RunResultState
from rich.console import Console

from my_project.errors import present_error
from my_project.examples import extract_entities as extract_entities_example
from my_project.runner import (
ExecutionMode,
fetch_run_result,
fetch_run_status,
progress_console,
run_blocking,
run_durable_attended,
start_detached,
wait_for_run,
)

ResultT = TypeVar("ResultT")

app = typer.Typer(no_args_is_help=True, help="Run the demo Pipelex methods through the Pipelex API.")
runs_app = typer.Typer(no_args_is_help=True, help="Inspect and resume durable runs by id.")
app.add_typer(runs_app, name="runs")

# Results go to stdout (pipeable); progress/status chatter goes to stderr (see runner.py).
output_console = Console()

MODE_HELP = "How to execute the run: `durable` (start + poll, survives anything) or `blocking` (single call, ~30s cap on hosted)."
DETACH_HELP = "Start the run and exit immediately; fetch it later with `my-project runs ...` (durable mode only)."


@app.callback()
def main() -> None:
"""Load .env so PIPELEX_BASE_URL / PIPELEX_API_KEY are available."""
load_dotenv()


@app.command(name="extract-entities")
def extract_entities(
text: Annotated[str | None, typer.Argument(help="The text to extract entities from.")] = None,
file: Annotated[Path | None, typer.Option("--file", help="Read the input text from a file instead of the argument.")] = None,
mode: Annotated[ExecutionMode, typer.Option(envvar="PIPELEX_EXECUTION_MODE", help=MODE_HELP)] = ExecutionMode.DURABLE,
detach: Annotated[bool, typer.Option("--detach", help=DETACH_HELP)] = False,
) -> None:
"""Extract people, organizations, and dates from a piece of text."""
input_text = _read_text_input(text=text, file=file)
bundle = extract_entities_example.BUNDLE_PATH.read_text()
results = _dispatch(
pipe_code=extract_entities_example.PIPE_CODE,
bundle=bundle,
inputs={"text": input_text},
mode=mode,
detach=detach,
)
if results is None:
return
# Narrow into the typed model (validates the concept's shape), then print it
# as JSON — the same rendering `runs result` / `runs wait` give.
entities = extract_entities_example.parse(results)
output_console.print_json(data=entities.model_dump())


@runs_app.command(name="status")
def runs_status(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None:
"""Show a run's coarse status without waiting."""
run = _run_cli(fetch_run_status(run_id))
pipe_part = f" (pipe: {run.pipe_code})" if run.pipe_code else ""
output_console.print(f"{run.pipeline_run_id}: [bold]{run.status}[/bold]{pipe_part}")
if run.degraded:
output_console.print("[yellow]Status is degraded — last-known value, the status backend was unreachable; retry shortly.[/yellow]")


@runs_app.command(name="result")
def runs_result(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None:
"""Fetch a run's result if it is finished (no waiting)."""
state = _run_cli(fetch_run_result(run_id))
_render_result_state(state)


@runs_app.command(name="wait")
def runs_wait(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None:
"""Poll a run to completion, then print its raw result."""
results = _run_cli(wait_for_run(run_id))
_print_raw_results(results)


def _read_text_input(*, text: str | None, file: Path | None) -> str:
if text is not None and file is not None:
msg = "Give the text either as an argument or via --file, not both."
raise typer.BadParameter(msg)
if file is not None:
return file.read_text()
if text is not None:
return text
msg = "Give the text to process as an argument, or point --file at a text file."
raise typer.BadParameter(msg)


def _dispatch(*, pipe_code: str, bundle: str, inputs: dict[str, Any], mode: ExecutionMode, detach: bool) -> RunResults | None:
"""Run the pipe in the requested mode; returns None when detached (id already printed)."""
if detach:
match mode:
case ExecutionMode.BLOCKING:
msg = "--detach starts a durable run; it cannot be combined with --mode blocking."
raise typer.BadParameter(msg)
case ExecutionMode.DURABLE:
pass
run_id = _run_cli(start_detached(pipe_code=pipe_code, bundle=bundle, inputs=inputs))
print(run_id)
progress_console.print(f"Run started — fetch it later with: [bold]my-project runs wait {run_id}[/bold]")
return None
match mode:
case ExecutionMode.BLOCKING:
return _run_cli(run_blocking(pipe_code=pipe_code, bundle=bundle, inputs=inputs))
case ExecutionMode.DURABLE:
return _run_cli(run_durable_attended(pipe_code=pipe_code, bundle=bundle, inputs=inputs))


def _run_cli(coro: Coroutine[Any, Any, ResultT]) -> ResultT:
"""Await a runner coroutine, presenting SDK errors and Ctrl-C as clean exits."""
try:
return asyncio.run(coro)
except (PipelineRequestError, httpx.HTTPStatusError) as exc:
presentation = present_error(exc)
progress_console.print(f"[red]Error:[/red] {presentation.message}")
if presentation.hint:
progress_console.print(f"[yellow]Hint:[/yellow] {presentation.hint}")
raise typer.Exit(1) from exc
except KeyboardInterrupt as exc:
# The resume hint was already printed by the runner; the run keeps executing server-side.
raise typer.Exit(130) from exc


def _render_result_state(state: RunResultState) -> None:
match state:
case RunResultRunning():
progress_console.print(
f"Run {state.pipeline_run_id} is still running — wait for it with: [bold]my-project runs wait {state.pipeline_run_id}[/bold]"
)
case RunResultCompleted():
_print_raw_results(state.result)
case RunResultFailed():
progress_console.print(f"[red]Run {state.pipeline_run_id} ended with status {state.status}: {state.message}[/red]")
raise typer.Exit(1)


def _print_raw_results(results: RunResults) -> None:
"""Print the run's main content as JSON — generic, no per-example narrowing."""
output_console.print_json(data=results.main_stuff)


if __name__ == "__main__":
app()
Loading
Loading