diff --git a/.claude/skills/bootstrap/SKILL.md b/.claude/skills/bootstrap/SKILL.md index e49cb33..ed78a1c 100644 --- a/.claude/skills/bootstrap/SKILL.md +++ b/.claude/skills/bootstrap/SKILL.md @@ -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/` → `/` (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 `/generated` and `/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 diff --git a/.claude/skills/bootstrap/scripts/bootstrap.py b/.claude/skills/bootstrap/scripts/bootstrap.py index c7401cf..05d0ff9 100644 --- a/.claude/skills/bootstrap/scripts/bootstrap.py +++ b/.claude/skills/bootstrap/scripts/bootstrap.py @@ -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: @@ -441,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: diff --git a/CHANGELOG.md b/CHANGELOG.md index f4a8d7d..8850b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - **Each execution mode is now a self-contained copy-paste unit.** A mode used to be an `ExecutionMode` value threaded through a dispatch chain (CLI command → `_dispatch()` → `match` → `piper/runner.py` → the SDK), so answering "how do I call the API?" meant hopping through four layers, and the `match` existed only to showcase all three lifecycles behind one option — nothing anyone would copy. Now each mode is one file (`piper/blocking/cli.py`, `piper/attended/cli.py`, `piper/detached/cli.py`) holding its commands, its SDK lifecycle, and its progress rendering, with one public lifecycle helper that *is* the mode (`execute_pipe` / `start_and_wait` / `start_pipe`). `piper/runner.py`, `ExecutionMode`, `_dispatch()`, and the `RunResults` adaptation that existed only to give the dispatch one return type are all deleted; `piper/cli.py` shrinks to a `load_dotenv` callback plus three `add_typer` calls. New: `docs/cli-architecture.md`. - **Only mode-orthogonal code is shared: `piper/inputs.py` (new) and `piper/errors.py`.** `piper/file_input.py` is merged into `piper/inputs.py`, which now also owns the text-or-file input helper (`read_text_input`). Lifecycle code is never shared — the demo commands are deliberately near-duplicated across the three mode files (diff two of them and only the lifecycle helper differs), and `tests/unit/test_mode_symmetry.py` guards that duplication against drift. The hints in `piper/errors.py` 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 `, which is exactly what it has become). - **The typed output models are now generated from the bundles, not hand-written.** `pipelex codegen` projects each method's concepts into `piper/generated//models.py` (a stamped module plus a `codegen.lock` recording the artifact set). `generate-image` parses into the generated built-in `Image` model (natives are materialized into the generated client), replacing the hand-written `GeneratedImage`. -- **Removed the `piper/examples/` layer.** Each demo is now one self-contained command in `piper/cli.py`: bundle path (via a single `METHODS_DIR` constant), pipe code, and inline narrowing into the generated model (`Model.model_validate(results.main_stuff)`) — no more per-demo wrapper module hiding that line behind a `parse()` indirection. The `parse()` unit tests went with it (they only exercised pydantic's `model_validate`); the e2e tests narrow inline the same way. +- **Removed the `piper/examples/` layer.** Each demo is now one self-contained command in its mode file (`piper//cli.py`): bundle path (each mode file owns its `METHODS_DIR` constant), pipe code, and inline narrowing into the generated model (`Model.model_validate(main_stuff)`) — no more per-demo wrapper module hiding that line behind a `parse()` indirection. The `parse()` unit tests went with it (they only exercised pydantic's `model_validate`); the e2e tests narrow inline the same way. - **Fixed the `/bootstrap` pyproject transform.** Its per-key edits had gone stale against the current `pyproject.toml` (multi-line `packages` array with the `piper.generated.*` subpackages, quoted package-data keys, the `piper/generated` ruff exclude), so bootstrapping aborted on the survivor check. Package-name tokens in pyproject are now rewritten generically (quoted-exact + dotted/path positions), which no longer goes stale when the package list changes; the bootstrap test fixture now mirrors the real pyproject shapes. - **New make targets:** `make codegen` regenerates the typed clients and the runnable `inputs.template.json` scaffolds beside each bundle; `make codegen-check` verifies offline (pure hashing, no network or API key) that the generated clients are current. The `pipelex` CLI is not a starter dependency — point the `PIPELEX` make variable at a pipelex install that ships `codegen`. CI wiring of `codegen-check` lands once a released pipelex ships the command. - **New offline smoke tests** (`tests/unit/test_generated_clients.py`): the generated modules import, carry the stamp + lock, round-trip their serialization, and each committed input template names exactly the inputs the CLI dispatches. diff --git a/CLAUDE.md b/CLAUDE.md index 0a67b5a..75d2f51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi - **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 `). 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//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 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 every lifecycle helper returns `main_stuff`, and each demo command narrows it inline — e.g. `ExtractedEntities.model_validate(main_stuff)` — into the generated model. There is no per-example wrapper layer. +- 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//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`. diff --git a/README.md b/README.md index 8b27750..f374394 100644 --- a/README.md +++ b/README.md @@ -168,10 +168,10 @@ The other demos run through the exact same path — they differ only in their in **The mode is the command group, not an option.** There are three, and every demo runs in all three: ```bash -uv run piper blocking extract-entities | summarize-pdf | generate-image -uv run piper attended extract-entities | summarize-pdf | generate-image -uv run piper detached extract-entities | summarize-pdf | generate-image -uv run piper detached wait | status | result +uv run piper blocking extract-entities # or summarize-pdf, generate-image +uv run piper attended extract-entities # or summarize-pdf, generate-image +uv run piper detached extract-entities # or summarize-pdf, generate-image +uv run piper detached wait # or status, result ``` Read them in that order — each one is a single self-contained file (`piper//cli.py`) you can copy straight into your own project. diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index 9bcc120..051432c 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -5,10 +5,10 @@ This starter has two jobs: show how easy it is to call the Pipelex API, and be c So the execution mode is not an option you pass, it is the command group you type: ``` -piper blocking extract-entities | summarize-pdf | generate-image -piper attended extract-entities | summarize-pdf | generate-image -piper detached extract-entities | summarize-pdf | generate-image -piper detached wait | status | result +piper blocking extract-entities # or summarize-pdf, generate-image +piper attended extract-entities # or summarize-pdf, generate-image +piper detached extract-entities # or summarize-pdf, generate-image +piper detached wait # or status, result ``` ## The layout @@ -23,7 +23,7 @@ piper/ detached/cli.py # the whole detached mode + the run-id lifecycle commands ``` -Each mode file is a **copy-paste unit**: that file plus `inputs.py` plus `errors.py` is everything you need to lift the mode into your own project. Nothing else in `piper/` is load-bearing for it. +Each mode file is a **copy-paste unit**: that file plus `inputs.py` plus `errors.py` is all the *lifecycle* code you need to lift the mode into your own project. A runnable copy also carries the method-specific artifacts the demos reference — the bundles (`piper/methods/`), the generated models (`piper/generated/`), and the sample document — but those are exactly what you replace with your own method anyway. No other code in `piper/` is load-bearing for a mode. ## The sharing rule @@ -41,7 +41,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. 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. +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 `piper/errors.py`), narrows the result into its *generated* model, prints JSON. | Mode | Lifecycle helper | SDK calls | What the demo prints | @@ -52,19 +52,19 @@ All three have the same four-part shape, so they diff cleanly: `detached` additionally owns the run-id lifecycle: `attend_run()` backs `wait`, and thin fetchers back `status` and `result`. It is the only mode with more than the demos, because "start now, collect later" is only a complete story if you can come back for the result. -All three lifecycle helpers return the run's resolved `main_stuff` (the SDK types it `Any` — the content is polymorphic), and each command's `Model.model_validate(main_stuff)` is what restores type safety. The models are generated from the bundles; see [codegen.md](codegen.md). +The result-producing helpers — `execute_pipe()`, `start_and_wait()`, and detached's `attend_run()` — return the run's resolved `main_stuff` (the SDK types it `Any` — the content is polymorphic). In blocking and attended mode each demo command narrows it with `Model.model_validate(main_stuff)`, which is what restores type safety; the models are generated from the bundles (see [codegen.md](codegen.md)). Detached is different by design: `start_pipe()` returns only the run id, and the run-id commands (`wait`, `result`) print the output generically — at collection time the command doesn't know which method the run executed, so there is no model to narrow into. ## Two conventions worth copying **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=$(piper detached generate-image "…")` just works. -**Errors are caught 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 a `(message, hint)` pair via `piper/errors.py`, and exits non-zero. Nothing else is caught anywhere: 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 every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to a `(message, hint)` pair via `piper/errors.py`, and exits non-zero. 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. 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). `piper/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 `piper blocking`. The hints name the mode *groups*, because the fix for a failed run is usually another group: a blocking run that hit the ~30s cap tells you to rerun it with `piper attended`; a run that timed out while you waited tells you to resume it with `piper detached wait `; a durable run against a runner that can't do them tells you to use `piper blocking`. -**Every demo runs with zero arguments.** When you give neither an argument nor `--file`, the input helper returns a bundled sample (`piper/inputs.py`'s `SAMPLE_*` constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode. +**Every demo runs with zero arguments.** When you give neither an argument nor `--file`, the input helper returns a bundled sample (`piper/inputs.py`'s `SAMPLE_*` constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command once your API key is set; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode. ## Why `attended` and `detached`, not `durable` diff --git a/docs/codegen.md b/docs/codegen.md index 488e75d..92165e3 100644 --- a/docs/codegen.md +++ b/docs/codegen.md @@ -38,8 +38,8 @@ PIPELEX=/path/to/pipelex/.venv/bin/pipelex make codegen PIPELEX=/path/to/pipelex/.venv/bin/pipelex make codegen-check ``` -Once a released `pipelex` ships `codegen`, `codegen-check` is meant to run in CI so a bundle edit can never land without its regenerated client. +Once a released `pipelex` ships `codegen`, `codegen-check` is meant to run in CI to catch stale, hand-edited, or orphaned generated artifacts. It does not resolve bundles, so a bundle edit that was never regenerated still needs the other guard: run `make codegen` and fail on a dirty `git diff` (see above). ## Offline test floor -`tests/unit/test_generated_clients.py` runs everywhere (no pipelex CLI, no API key): it checks the generated modules import, carry the stamp + lock, round-trip their own serialization, and that each committed input template names exactly the inputs the CLI passes — so a regenerated template that no longer matches what `piper//cli.py` sends fails in CI even before `codegen-check` is wired in. +`tests/unit/test_generated_clients.py` runs everywhere (no pipelex CLI, no API key): it checks the generated modules import, carry the stamp + lock, round-trip their own serialization, and that each committed input template names exactly the inputs of the test's checked-in contract (its `METHODS` map, which mirrors the inputs each `piper//cli.py` sends) — so a regenerated template that drifts from that contract fails in CI even before `codegen-check` is wired in. diff --git a/piper/attended/cli.py b/piper/attended/cli.py index 73d3b65..32cde60 100644 --- a/piper/attended/cli.py +++ b/piper/attended/cli.py @@ -3,8 +3,9 @@ `client.start(...)` submits the run server-side (it survives anything, including the ~30s cap that kills `piper blocking`), and `client.wait_for_result(...)` polls it to completion from this terminal. The run id is printed *before* polling starts, -so a Ctrl-C never loses the run: it keeps executing server-side and you resume it -with `piper detached wait `. +so once you see it, a Ctrl-C doesn't lose the run: it keeps executing server-side and +you resume it with `piper detached wait `. (A Ctrl-C while the start request is +still in flight is the one window with no id to resume from.) The SDK also offers `start_and_wait()`, a self-healing one-liner that picks the right path by itself — that is the production shortcut. This starter spells the diff --git a/piper/errors.py b/piper/errors.py index 4952888..6128925 100644 --- a/piper/errors.py +++ b/piper/errors.py @@ -116,7 +116,9 @@ def _read_problem_json(response: httpx.Response) -> dict[str, Any]: """Best-effort parse of an RFC 7807 problem+json body; `{}` when it isn't JSON.""" try: body: Any = response.json() - except json.JSONDecodeError: + except (json.JSONDecodeError, UnicodeDecodeError): + # UnicodeDecodeError: `response.json()` is `json.loads(response.content)`, + # which raises it (not JSONDecodeError) on a non-UTF-8 body. return {} if isinstance(body, dict): # JSON object keys are always strings. diff --git a/piper/inputs.py b/piper/inputs.py index 9e0c1bf..d753139 100644 --- a/piper/inputs.py +++ b/piper/inputs.py @@ -51,12 +51,16 @@ def read_text_input(*, text: str | None, file: Path | None, sample: str) -> Text command runs with no arguments at all. Raises: - typer.BadParameter: both an argument and `--file` were given. + typer.BadParameter: both an argument and `--file` were given, or `--file` + does not point at a readable file. """ 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: + if not file.is_file(): + msg = f"No such file: {file}" + raise typer.BadParameter(msg) return TextInput(text=file.read_text(), is_sample=False) if text is not None: return TextInput(text=text, is_sample=False) diff --git a/tests/e2e/test_extract_entities.py b/tests/e2e/test_extract_entities.py index 8bb017a..24712bb 100644 --- a/tests/e2e/test_extract_entities.py +++ b/tests/e2e/test_extract_entities.py @@ -16,7 +16,7 @@ class TestExtractEntities: async def test_blocking(self): # The blocking lifecycle end to end: one `execute` call, then narrow. - # Extraction finishes well under the hosted ~30s cap, so it is the demo blocking mode owns. + # Extraction finishes well under the hosted ~30s cap, so the blocking mode owns this demo. bundle = BUNDLE_PATH.read_text() main_stuff = await execute_pipe(pipe_code="extract_entities", bundle=bundle, inputs={"text": SAMPLE_TEXT}) entities = ExtractedEntities.model_validate(main_stuff) diff --git a/tests/unit/test_bootstrap_script.py b/tests/unit/test_bootstrap_script.py index 67d632f..3f9cd5c 100644 --- a/tests/unit/test_bootstrap_script.py +++ b/tests/unit/test_bootstrap_script.py @@ -67,6 +67,16 @@ def write_template(root: Path, *, extra_pyproject: str = "") -> None: encoding="utf-8", ) (root / "README.md").write_text("# Piper\n\nRun `piper extract-entities`.\n", encoding="utf-8") + (root / "Makefile").write_text( + "codegen:\n" + "\t@$(PIPELEX_RUN) codegen types --target python-pydantic --output piper/generated/extract_entities piper/methods/extract-entities\n" + "\n" + "codegen-check:\n" + "\t@$(PIPELEX_RUN) codegen check piper/generated/extract_entities\n", + encoding="utf-8", + ) + (root / "docs").mkdir() + (root / "docs" / "codegen.md").write_text("Generated models live in `piper/generated//models.py`.\n", encoding="utf-8") (root / "CLAUDE.md").write_text("The `piper` CLI lives in `piper/cli.py`.\n", encoding="utf-8") (root / "LICENSE").write_text("MIT License\n\nCopyright (c) 2025 Example\n", encoding="utf-8") (root / "piper" / "cli.py").write_text('"""The piper CLI."""\n', encoding="utf-8") @@ -75,11 +85,30 @@ def write_template(root: Path, *, extra_pyproject: str = "") -> None: (root / "tests" / "test_cli.py").write_text("from piper.cli import app\n", encoding="utf-8") +def test_validate_package_rejects_placeholder_colliding_names() -> None: + # A package like `piper_tools` re-matches the placeholder regex after its own + # insertion (piper_tools -> piper_tools_tools in pyproject), so it is refused + # up front instead of producing a corrupted, non-building project. + bootstrap = load_bootstrap() + + with pytest.raises(SystemExit) as exc_info: + bootstrap.validate_package("piper_tools") + + assert "piper" in str(exc_info.value) + + +def test_validate_package_allows_names_embedding_piper() -> None: + # No word boundary before "piper" here, so it never collides with the placeholder. + bootstrap = load_bootstrap() + + bootstrap.validate_package("sandpiper_tools") + + def test_survivor_check_allows_requested_values_containing_piper(tmp_path: Path) -> None: bootstrap = load_bootstrap() write_template(tmp_path) - names = bootstrap.Names(dist="piper-tools", package="piper_tools", title="Piper Tools") + names = bootstrap.Names(dist="sandpiper-tools", package="sandpiper_tools", title="Sandpiper Tools") opts = bootstrap.Options( description="Build Piper workflows", author_name="Piper Team", @@ -94,6 +123,37 @@ def test_survivor_check_allows_requested_values_containing_piper(tmp_path: Path) bootstrap.run(tmp_path, names, opts) +def test_run_rewrites_makefile_and_docs_paths(tmp_path: Path) -> None: + # The Makefile codegen targets and the docs reference piper/... paths; a + # bootstrap that skips them leaves `make codegen` pointing at a directory + # that no longer exists on the renamed project. + bootstrap = load_bootstrap() + write_template(tmp_path) + + names = bootstrap.Names(dist="invoice-extractor", package="invoice_extractor", title="Invoice Extractor") + opts = bootstrap.Options( + description="Extract invoice fields", + author_name=None, + author_email=None, + repo_url=None, + lic=bootstrap.License(kind="mit", spdx="MIT", holder=None, year=2026), + clean=False, + dry_run=False, + use_git=False, + ) + + bootstrap.run(tmp_path, names, opts) + + makefile = (tmp_path / "Makefile").read_text(encoding="utf-8") + assert "--output invoice_extractor/generated/extract_entities invoice_extractor/methods/extract-entities" in makefile + assert "codegen check invoice_extractor/generated/extract_entities" in makefile + assert "piper" not in makefile + + docs = (tmp_path / "docs" / "codegen.md").read_text(encoding="utf-8") + assert "invoice_extractor/generated//models.py" in docs + assert "piper" not in docs + + def test_survivor_check_still_rejects_unhandled_template_tokens(tmp_path: Path) -> None: bootstrap = load_bootstrap() # A bare `piper` word inside prose (not quoted-exact, not in package position) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index f469cb0..2bfc167 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -58,6 +58,16 @@ def test_start_without_async_orchestration_hints_blocking(self): assert presentation.hint is not None assert "piper blocking" in presentation.hint + def test_http_error_with_undecodable_body_falls_back_to_status(self): + # A non-UTF-8 body makes `response.json()` raise UnicodeDecodeError (not + # JSONDecodeError); the best-effort problem+json parse must still fall + # back to the status-only message instead of crashing mid-presentation. + request = httpx.Request("POST", "https://api.pipelex.com/v1/start") + response = httpx.Response(400, request=request, headers={"content-type": "application/problem+json"}, content=b"\xffnot-json") + presentation = present_error(httpx.HTTPStatusError("boom", request=request, response=response)) + assert presentation.message == "The API answered 400 Bad Request." + assert presentation.hint is None + def test_http_error_surfaces_the_problem_detail(self): problem = {"title": "Bad input", "detail": "Missing required input 'text'.", "status": 400} presentation = present_error(_http_status_error(400, problem=problem)) diff --git a/tests/unit/test_inputs.py b/tests/unit/test_inputs.py index d87fe8e..0e11583 100644 --- a/tests/unit/test_inputs.py +++ b/tests/unit/test_inputs.py @@ -26,6 +26,12 @@ def test_read_text_input_rejects_both(self, tmp_path: Path): with pytest.raises(typer.BadParameter): read_text_input(text="inline text", file=input_file, sample="the sample") + def test_read_text_input_rejects_a_missing_file(self, tmp_path: Path): + # Same clean CLI error as the `summarize-pdf` document path — a mistyped + # --file must not surface as a raw FileNotFoundError traceback. + with pytest.raises(typer.BadParameter): + read_text_input(text=None, file=tmp_path / "nope.txt", sample="the sample") + def test_read_text_input_falls_back_to_the_sample(self): resolved = read_text_input(text=None, file=None, sample="the sample") assert resolved.text == "the sample" diff --git a/wip/pr-62-review-notes.md b/wip/pr-62-review-notes.md new file mode 100644 index 0000000..0bca037 --- /dev/null +++ b/wip/pr-62-review-notes.md @@ -0,0 +1,18 @@ +# PR #62 review — deferred items + +Review-agent findings on [PR #62](https://github.com/Pipelex/pipelex-starter-python/pull/62) that were verified as real but deliberately **not** fixed in the PR. Each entry records why it was deferred and what a future fix would look like. + +## Sample PDF is not packaged into a wheel + +- **Reporters:** greptile-apps (`piper/inputs.py:33`), cubic-dev-ai (`piper/attended/cli.py:94`) +- **Finding (verified):** `SAMPLE_INVOICE` resolves to the repo-root `samples/sample-invoice.pdf` via `Path(__file__).parent.parent`, and `pyproject.toml` only ships package data under `piper.*`. On a true wheel install, the zero-argument `summarize-pdf` demo would fail with `FileNotFoundError`. +- **Why deferred:** this repo is a template — README says "Use this template", install is `make install` (uv, editable), and wheel distribution is not a supported or advertised path. The repo-root `samples/` location is deliberately user-facing (README documents `samples/sample-invoice.pdf` as a path to inspect and replace). Fixing this would move the sample under `piper/` (importlib.resources + package-data) and regress that documented UX for a scenario nobody uses. +- **Revisit if:** the template ever starts being consumed as an installed wheel. + +## Ctrl-C during the initial `client.start` can lose the run id + +- **Reporter:** cubic-dev-ai (`piper/attended/cli.py:54`) +- **Finding (verified):** a Ctrl-C landing while the `client.start` request is in flight — after the server committed the run but before the client printed the id — leaves a durable run with no id to resume from. The window is real in both attended and detached modes. +- **Why deferred:** unsolvable client-side. `PipelexAPIClient.start` has no idempotency-key parameter and no accepted-id recovery; closing the window needs server/SDK support (idempotency keys on `/v1/start`). Building persistence around a not-yet-received id in a teaching starter would be overengineering. The attended module docstring was softened in the PR to scope the "Ctrl-C doesn't lose the run" guarantee to the post-start window. +- **Where the real fix lives:** `pipelex-sdk-python` / the platform API (idempotent submission), not this repo. +