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
2 changes: 1 addition & 1 deletion .claude/skills/bootstrap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The dry run prints the package-directory rename and the list of files that would

Re-run the exact same command **without** `--dry-run`. The script:
- renames `piper/` → `<package>/` (via `git mv`, so history follows). The e2e test file is named after its demo (`tests/e2e/test_extract_entities.py`), not the project, so nothing in `tests/` is renamed — only its contents are edited.
- substitutes the name across `pyproject.toml`, `README.md`, `CLAUDE.md`, the package's `.py`/`.mthds` files, and the tests — using context-aware rules: the dash dist form in CLI-command / distribution positions, the underscore package form in imports and paths, the title in prose. `pyproject.toml` is handled with targeted per-key edits (its package-list arrays need the package form in a bare-string position). It then asserts no placeholder token survived, and aborts with the offending locations if one did.
- substitutes the name across `pyproject.toml`, `README.md`, `CLAUDE.md`, the `Makefile` (the codegen targets point at `<package>/generated` and `<package>/methods` paths), the package's `.py`/`.mthds` files, the tests, and the docs under `docs/` — using context-aware rules: the dash dist form in CLI-command / distribution positions, the underscore package form in imports and paths, the title in prose. `pyproject.toml` is handled with targeted per-key edits (its package-list arrays need the package form in a bare-string position). It then asserts no placeholder token survived, and aborts with the offending locations if one did.
- fills in description, and (if given) author, repo URL
- applies the license choice in all three places: the `LICENSE` body, `license = "..."` in `pyproject.toml`, and the README license line
- strips the README template block
Expand Down
37 changes: 25 additions & 12 deletions .claude/skills/bootstrap/scripts/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ def validate_package(package: str) -> None:
)
if keyword.iskeyword(package):
sys.exit(f"Invalid package name {package!r}: it is a Python keyword.")
# A name like `piper_tools` re-matches the placeholder regex after its own
# insertion (the pyproject transform would corrupt it into `piper_tools_tools`),
# so refuse it up front. Names merely embedding the token (e.g. `sandpiper`)
# have no word boundary before `piper` and stay allowed.
if PIPER_PACKAGE_RE.search(package):
sys.exit(
f"Invalid package name {package!r}: it collides with the template's 'piper' placeholder — pick a name that doesn't start with 'piper_'."
)


def validate_dist(dist: str) -> None:
Expand Down Expand Up @@ -239,27 +247,30 @@ def transform_pyproject(text: str, names: Names, opts: "Options") -> str:
else:
text = text.replace(f"yourusername/{TEMPLATE_NAME}", f"yourusername/{names.dist}")

# Name tokens: targeted per-key edits rather than the character-after pass,
# because a package name sitting bare in a TOML array (`packages = ["piper"]`)
# or as a bare table key looks like a command position to the generic
# heuristic. Each occurrence is edited by its key; the post-run assertion in
# run() is the backstop that catches any key not handled here.
# Name tokens: targeted edits rather than the character-after pass, because
# a package name sitting bare in a TOML array (`packages = ["piper"]`) or as
# a bare table key looks like a command position to the generic heuristic.
# The name-bearing keys are edited by key first; the post-run assertion in
# run() is the backstop that catches any occurrence not handled here.
key_edits = {
# [project].name -> distribution name
f'name = "{TEMPLATE_NAME}"': f"name = {toml_str(names.dist)}",
# [project.scripts]: <dist-command> = "<package>.cli:app"
f'{TEMPLATE_NAME} = "{TEMPLATE_NAME}.cli:app"': f'{names.dist} = "{names.package}.cli:app"',
# [tool.setuptools] packages (import names)
f'packages = ["{TEMPLATE_NAME}", "{TEMPLATE_NAME}.examples"]': f'packages = ["{names.package}", "{names.package}.examples"]',
# [tool.setuptools.package-data] table key (import name)
# [tool.setuptools.package-data] bare table key (import name)
f'{TEMPLATE_NAME} = ["py.typed"': f'{names.package} = ["py.typed"',
# [tool.mypy] packages (import name)
f'packages = ["{TEMPLATE_NAME}"]': f'packages = ["{names.package}"]',
# [tool.pyright] include (import name)
f'include = ["{TEMPLATE_NAME}", "tests"]': f'include = ["{names.package}", "tests"]',
}
for old_text, new_text in key_edits.items():
text = text.replace(old_text, new_text)
# Everything left is the package (import/path) form: quoted-exact array
# entries ("piper" in [tool.setuptools] packages / [tool.mypy] / [tool.pyright]),
# and dotted/path positions (the "piper.generated.*" package + package-data
# entries, the "piper/generated" ruff exclude, prose comments about
# piper/methods/*). The name-bearing keys above have already been rewritten,
# so the quoted-exact replace plus the package-position rule cover the rest —
# and neither goes stale when a package is added to or removed from an array.
text = text.replace(f'"{TEMPLATE_NAME}"', f'"{names.package}"')
text = PIPER_PACKAGE_RE.sub(names.package, text)
return text


Expand Down Expand Up @@ -438,12 +449,14 @@ def gather_target_files(root: Path, names: Names) -> list[Path]:
root / "pyproject.toml",
root / "README.md",
root / "CLAUDE.md",
root / "Makefile",
root / "LICENSE",
]
pkg_dir = root / names.package
candidates += sorted(pkg_dir.rglob("*.py"))
candidates += sorted(pkg_dir.rglob("*.mthds"))
candidates += sorted((root / "tests").rglob("*.py"))
candidates += sorted((root / "docs").rglob("*.md"))
seen: set[Path] = set()
files: list[Path] = []
for path in candidates:
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## [v0.14.0] - 2026-07-15

### Added

- **Zero-argument demo fallbacks:** every demo now runs with zero arguments, falling back to a built-in sample (e.g. `samples/sample-invoice.pdf` for `summarize-pdf`) and printing a notice to stderr, so stdout stays pipeable and a fresh clone shows a working result on the first command. `read_text_input()` gains a `sample` parameter and returns a `TextInput(text, is_sample)`.
- **Generated typed clients:** output models are now generated from `.mthds` bundles into `piper/generated/` via `pipelex codegen`, replacing hand-written models (`generate-image` now parses into the generated `Image` model, replacing the hand-written `GeneratedImage`). Added `make codegen` to regenerate clients and templates, and `make codegen-check` to verify offline (via hashing against each `codegen.lock`) that generated clients are up to date. Each `codegen.lock` ships as package data, so the check also works against an installed (wheel) copy, not just a git checkout.
- **New documentation:** added `docs/cli-architecture.md`, describing the copy-paste CLI layout, and `docs/codegen.md`, explaining the generated-models workflow.
- **Offline smoke tests:** added `tests/unit/test_generated_clients.py` to verify generated modules import correctly, carry the stamp + lock, round-trip their serialization, and match the committed input templates to the CLI's inputs; and `tests/unit/test_mode_symmetry.py` to guard against drift between CLI modes.
- **Bootstrap validation:** `/bootstrap` now rejects package names that collide with the template's `piper` placeholder (e.g. `piper_tools`, which would corrupt into `piper_tools_tools` under the pyproject transform).

### Changed

- **Execution mode is now the command group, not an option (Breaking):** `--mode`, `--detach`, and the `PIPELEX_EXECUTION_MODE` env var are gone — invoke the mode explicitly: `piper blocking <demo>`, `piper attended <demo>`, or `piper detached <demo>`. The top-level `runs` command moves under detached mode (`piper detached status|result|wait <id>`). There is no default mode anymore; the mode is explicit in every invocation, which is itself the lesson. The middle mode is named `attended`, not `durable`, because detached runs are durable too — the axis the names describe is who waits.
- **Typed `Image` dimensions (Breaking):** the generated `Image` model now uses optional integer `width` and `height` fields instead of an untyped `size` dict, with native field descriptions sourced from the standard's pinned definitions. Regenerated all committed clients (stamps, locks, and fingerprints updated).
- **CLI architecture:** each execution mode is now a self-contained, copy-paste unit (`piper/<mode>/cli.py`) with its own commands, SDK lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`), and progress rendering; lifecycle code is no longer shared. Only mode-orthogonal code remains shared: `piper/inputs.py` (text/file inputs and document envelopes — `piper/file_input.py` is merged into it) and `piper/errors.py`, whose hints now name the mode groups (a blocking run that hits the ~30s cap points at `piper attended`; an interrupted attended run points at `piper detached wait <id>`).
- **Structured HTTP errors:** protocol-route HTTP errors now parse and surface the API's RFC 7807 `problem+json` body (`title`, `detail`, machine `error_type`) instead of httpx's stringification, with hints pointing to the correct CLI mode based on error type (e.g. `StartRequiresAsyncOrchestration` points at `piper blocking`).
- **E2E tests:** end-to-end tests now call the mode lifecycle helpers directly, with one execution mode per demo (`extract-entities` → blocking, `summarize-pdf` → attended, `generate-image` → detached) for full matrix coverage.
- `piper/generated` is excluded from ruff (reformatting generated files would trip the drift check) but remains fully type-checked.

### Fixed

- **Bootstrap `pyproject.toml` transform:** `/bootstrap` now rewrites `pyproject.toml` using generic, context-aware rules (quoted-exact and dotted/path positions) instead of per-key edits, preventing staleness as the package list changes, and correctly handles the multi-line `packages` array with the `piper.generated.*` subpackages, quoted package-data keys, and the `piper/generated` ruff exclude.

### Removed

- **Dispatch layer:** removed `piper/runner.py`, the central `_dispatch()` chain, and the `ExecutionMode` enum, in favor of the self-contained mode CLIs; `piper/cli.py` shrinks to a `load_dotenv` callback plus three `add_typer` calls.
- **`piper/examples/` layer:** removed the per-demo wrapper modules; demo logic and inline model narrowing (`Model.model_validate(main_stuff)`) now live directly in the mode CLI files. The `parse()` unit tests went with it (they only exercised pydantic's `model_validate`).
- **`piper/file_input.py`:** merged into the new shared `piper/inputs.py` module.

## [v0.13.0] - 2026-07-07

- **Breaking:** renamed the starter's placeholder project from `my-project` / `my_project` / `My Project` to `piper` / `Piper`. The console command is now `uv run piper ...`, the template package is `piper/`, `pyproject.toml` points at `piper.cli:app`, and the e2e extract-entities test file no longer carries the project placeholder in its name.
Expand Down
15 changes: 10 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Run specific tests (local only): `make tp TEST=test_function_name`
- `make li` - Lock + install
- `make cleanderived` - Remove caches/compiled files (useful when linters get confused)
- `make validate` / `make v` - Lint/validate the `.mthds` bundle with plxt (offline)
- `make codegen` - Regenerate the typed clients + input templates from the `.mthds` methods (needs the `pipelex` CLI — see below)
- `make codegen-check` - Verify generated clients are current (offline, pure hashing against each `codegen.lock`)
- `make tb` - Quick boot test (constructs the API client, no network)
- `make fui` - Fix unused imports only
- `make plxt-format` - Format `.mthds`/`.toml` files with plxt
Expand All @@ -35,14 +37,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_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
- The `piper` CLI (`piper/cli.py`) is a Typer app; `piper/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 `piper 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 `piper/examples/`, one "copy me" module per demo (bundle path, output model, `parse()`) — `extract_entities.parse()` validates `results.main_stuff` into a typed `ExtractedEntities` model. SDK errors are mapped to CLI-facing messages + hints in `piper/errors.py`.
- Three demo commands share that dispatch: `extract-entities` (text in), `summarize-pdf` (a *file* in — `piper/file_input.build_document_input()` encodes a local file as a base64 `data:` URL wrapped in a `{"concept": "Document", "content": …}` envelope), and `generate-image` (prompt in). `generate-image` is the deliberate slow case that overruns the ~30s blocking cap, so it's how the starter demonstrates the durable-vs-blocking difference concretely. `samples/sample-invoice.pdf` is shipped for `summarize-pdf`.
- **The execution mode is the command group, not an option.** There are exactly three, each a self-contained Typer sub-package: `piper blocking …` (`client.execute` — one call, dies at the hosted ~30s cap), `piper attended …` (`client.start` + `client.wait_for_result` — durable, you wait), `piper detached …` (`client.start` only — durable, you collect it later with `piper 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: `piper/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.** `piper/<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), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are the two that are orthogonal to execution: `piper/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 with the file base64-encoded into a `data:` URL, and the `SAMPLE_*` constants) and `piper/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 `piper blocking`"). 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 — `piper 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`) return `main_stuff`, and the blocking/attended demo commands narrow it inline — e.g. `ExtractedEntities.model_validate(main_stuff)` — into the generated model. Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare), and the run-id commands (`wait`/`result`) print the output generically — 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.
- **The typed models are generated, never hand-written.** `pipelex codegen` projects each bundle's concepts into `piper/generated/<method>/models.py` (stamped, locked by a sibling `codegen.lock`); the mode CLIs and the e2e tests import from there. Do NOT edit generated files — edit the bundle, then `make codegen` (regenerates models + `inputs.template.json` scaffolds) and `make codegen-check` (offline drift check). The `pipelex` CLI is not a dependency of this starter; point the `PIPELEX` make variable at a pipelex install that ships `codegen`. `piper/generated` is excluded from ruff (reformatting would trip the drift check) but fully type-checked. See `docs/codegen.md`.

## Project Structure

- Package: `piper/` (Python 3.11+, target 3.11)
- Tests: `tests/` (unit = offline CLI/example/error-mapping tests; integration = offline boot/bundle checks + API `validate`; e2e = full run via the API)
- Package: `piper/` (Python 3.11+, target 3.11) — root `cli.py` + one sub-package per execution mode (`blocking/`, `attended/`, `detached/`) + the two shared modules (`inputs.py`, `errors.py`). New mode sub-packages must be added to `[tool.setuptools] packages` in `pyproject.toml`.
- Tests: `tests/` (unit = offline per-mode CLI tests patching each mode's public lifecycle helper, plus mode-symmetry / error-mapping / generated-client tests; integration = offline boot/bundle checks + API `validate`; e2e = full run via the API, one execution mode per demo so all three get end-to-end coverage)
- 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 `piper/methods/<name>/main.mthds`
Expand Down
Loading
Loading