From cde53fe1e8640b174bf457de5b4faa9b45be1391 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 10:28:53 +0300 Subject: [PATCH 1/2] chore: bump planning convention 1.1.2 -> 2.0.0 Apply the lesnik512/planning-convention APPLY.md 2.0.0 update: swap index.py + templates and drop the plan.md template, flatten the two change folders to flat change files and drop their committed plan.md, merge the 2.0.0 convention prose into planning/README.md, retire "bundle" wording in CLAUDE.md/justfile, and record .convention-version=2.0.0. --- CLAUDE.md | 2 +- justfile | 2 +- planning/.convention-version | 2 +- planning/README.md | 71 +- planning/_templates/change.md | 4 +- planning/_templates/design.md | 34 +- planning/_templates/plan.md | 46 - ...> 2026-07-03.01-compose2pod-extraction.md} | 0 .../plan.md | 1446 ----------------- ...n.md => 2026-07-04.01-extension-fields.md} | 0 .../2026-07-04.01-extension-fields/plan.md | 310 ---- planning/index.py | 114 +- 12 files changed, 110 insertions(+), 1921 deletions(-) delete mode 100644 planning/_templates/plan.md rename planning/changes/{2026-07-03.01-compose2pod-extraction/design.md => 2026-07-03.01-compose2pod-extraction.md} (100%) delete mode 100644 planning/changes/2026-07-03.01-compose2pod-extraction/plan.md rename planning/changes/{2026-07-04.01-extension-fields/design.md => 2026-07-04.01-extension-fields.md} (100%) delete mode 100644 planning/changes/2026-07-04.01-extension-fields/plan.md diff --git a/CLAUDE.md b/CLAUDE.md index da6f0d0..70c9133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This repo follows Artur Shiriev's canonical planning convention `planning/.convention-version`). Before starting any non-trivial change, read [`planning/README.md`](planning/README.md)'s **Quick path** section — it is the authoritative process for choosing a lane (Full / Lightweight / Tiny) and -shaping the change bundle. Run `just check-planning` before pushing. +shaping the change file. Run `just check-planning` before pushing. ## Architecture diff --git a/justfile b/justfile index 8819eb9..c9647fb 100644 --- a/justfile +++ b/justfile @@ -24,7 +24,7 @@ lint-ci: index: uv run python planning/index.py -# Validate planning bundles/decisions; prints "planning: OK" or violations. +# Validate planning changes/decisions; prints "planning: OK" or violations. check-planning: uv run python planning/index.py --check diff --git a/planning/.convention-version b/planning/.convention-version index 45a1b3f..227cea2 100644 --- a/planning/.convention-version +++ b/planning/.convention-version @@ -1 +1 @@ -1.1.2 +2.0.0 diff --git a/planning/README.md b/planning/README.md index f59bec7..a28f7fe 100644 --- a/planning/README.md +++ b/planning/README.md @@ -14,25 +14,22 @@ This is `compose2pod`'s planning tree, adopted from Artur Shiriev's canonical **1. Choose a lane — first matching rule wins:** 1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** - (`design.md` + `plan.md`) + cross-cutting or multi-file · non-trivial test design → **Full** (design template) 2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no bundle, conventional + mechanical rename · single-line config → **Tiny** (no change file, conventional commit) 3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** - (`change.md`) + no public-API change · one straightforward test → **Lightweight** (change template) -Ambiguous between two? Take the heavier. A `change.md` that outgrows its lane -splits into `design.md` + `plan.md`. +Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. -**2. Create the bundle** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-/`, where `.NN` is a zero-padded -intra-day counter. Copy the matching template from +**2. Create the change file** (Full / Lightweight only): +`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded +intra-day counter — copied from the matching template (design or change) in [`_templates/`](_templates/). **3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the bundle's `summary:` to the +`architecture/.md`, finalize the change file's `summary:` to the realized result, and run `just check-planning` before pushing. ## Conventions @@ -48,14 +45,14 @@ realized result, and run `just check-planning` before pushing. - **`architecture/` (repo root) — the present.** One file per capability, plus a single `glossary.md` (the ubiquitous language); living prose, updated in the same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One folder per change, +- **`planning/changes/` — the past-and-pending.** One file per change, kept in place after ship. A change **promotes** its conclusions into the affected `architecture/.md` by hand **in the implementing PR, alongside the code** — the edit rides in the same diff and is reviewed with it, never applied as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the bundle stays in `changes/` as the *why*. +true; the change file stays in `changes/` as the *why*. ### Glossary @@ -77,9 +74,9 @@ Keep it a glossary, not a spec — no implementation detail. A change that introduces or sharpens a term updates `glossary.md` in the same PR, the same way a behavior change promotes into a capability file. -### Change bundles +### Change files -A change is a folder `changes/YYYY-MM-DD.NN-/`: +A change is a file `changes/YYYY-MM-DD.NN-.md`: - `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. @@ -88,25 +85,44 @@ A change is a folder `changes/YYYY-MM-DD.NN-/`: `summary` is written when the change is created (the intent one-liner) and **finalized at ship** to state the realized result — set in the implementing PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no folder move. `date` and `slug` are never written — they are -read from the bundle's directory name. +bookkeeping, no file move. `date` and `slug` are never written — they are +read from the file name. ### Three lanes | Lane | Artifacts | Use when | |------|-----------|----------| -| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | +| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | +| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | | **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | -Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits -into `design.md` + `plan.md`. +Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. + +### Plans are ephemeral + +The executable plan — task checklists, embedded code, commit sequences, +whatever the executor needs — is a working artifact, not history. Keep it out +of `changes/` and out of version control (git-ignored scratch, e.g. +`.superpowers/`). Once the change ships, the diff and the PR are the record +of execution; a committed plan duplicates them. `check-planning` rejects +anything in `changes/` that is not a flat change file. + +### Lean specs + +The change file is the single home of a change's rationale: + +- The PR body summarizes and links to the change file — it never restates it. +- Rejected alternatives live in `decisions/` and are referenced, not retold. +- Show a sketch when the design needs code; never the full diff-to-be. +- Delete template sections that don't apply — an empty section is ceremony. +- Most designs fit well under ~700 words; length must buy information. ### Artifacts at a glance -- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). -- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). -- **`change.md`** — both, condensed, for the lightweight lane. +- **design template** — the spec: the *thinking* (why, design, trade-offs, + scope); the change file it produces is the single home of rationale (see + [Lean specs](#lean-specs)). +- **change template** — the condensed spec for the lightweight lane. - **`releases/.md`** — per-release user-facing notes. - **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; spawns fix changes. @@ -120,11 +136,10 @@ Templates live in [`_templates/`](_templates/). ### Frontmatter -`date` and `slug` are **derived from the directory / file name** — never +`date` and `slug` are **derived from the file name** — never repeated in frontmatter. So: -- `design.md` / `change.md`: `summary` (single line) only. -- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `changes/*.md`: `summary` (single line) only. - `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional `supersedes` / `superseded_by`. - Files in `architecture/` carry **no** frontmatter — living prose, dated by git. @@ -137,7 +152,7 @@ only field the index renders. Run `just index` for the generated change/decision listing (never committed — it is a query over the files, not an artifact). `just check-planning` validates -bundle/decision names and required frontmatter. +change/decision names and required frontmatter. ## Other diff --git a/planning/_templates/change.md b/planning/_templates/change.md index d4c8962..5aa7e81 100644 --- a/planning/_templates/change.md +++ b/planning/_templates/change.md @@ -5,8 +5,8 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Change: One-line capitalized title **Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, split into -`design.md` + `plan.md`. +change, a single straightforward test. If it outgrows this, rewrite it from +the design template. ## Goal diff --git a/planning/_templates/design.md b/planning/_templates/design.md index d63e22d..17dbee1 100644 --- a/planning/_templates/design.md +++ b/planning/_templates/design.md @@ -4,6 +4,10 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Design: One-line capitalized title + + ## Summary One paragraph. What changes, at the level a reader needs to decide if this @@ -12,37 +16,23 @@ spec is worth reading in full. ## Motivation Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. Link to memory entries or earlier specs when relevant. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. Each item is -a sentence; one line each. +abstract complaints. ## Design -### 1. - What changes, in enough detail that a reader who has not seen the codebase -can follow. Code samples / diagrams welcome. +can follow. Sketches and interface fragments welcome; never the full +diff-to-be. Reference rejected alternatives in `decisions/` instead of +retelling them. -### 2. - -... - -## Operations - -Out-of-repo steps (DNS, infra, external account changes). Omit if none. - -## Out of scope +## Non-goals -Already covered above under Non-goals if appropriate. Repeat-list of -explicitly-excluded follow-ups belongs here when the list is long. +What is deliberately out of scope and (when nontrivial) why. One line each. ## Testing -How we know it landed correctly. New pytest? Smoke check on live URL? -Lint pass? Be specific. +How we know it landed correctly. Be specific: the command and the expected +signal. ## Risk diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md deleted file mode 100644 index 132d720..0000000 --- a/planning/_templates/plan.md +++ /dev/null @@ -1,46 +0,0 @@ -# — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** One sentence — what shipping this plan achieves. No design -rationale; link to the spec for that. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) - -**Commit strategy:** Per-task commits / single commit / squash on merge. -Whichever fits. - ---- - -### Task 1: - -**Files:** -- Modify: `path/to/file.py` -- Create: `path/to/new.py` - -One sentence on what this task accomplishes. No deeper reasoning — that's -in the spec. - -- [ ] **Step 1: ** - - Run / edit / verify command. Expected output. - -- [ ] **Step 2: ** - - ... - -- [ ] **Step 3: Commit** - - ```bash - git add path/to/file.py - git commit -m ": " - ``` - ---- - -### Task 2: ... diff --git a/planning/changes/2026-07-03.01-compose2pod-extraction/design.md b/planning/changes/2026-07-03.01-compose2pod-extraction.md similarity index 100% rename from planning/changes/2026-07-03.01-compose2pod-extraction/design.md rename to planning/changes/2026-07-03.01-compose2pod-extraction.md diff --git a/planning/changes/2026-07-03.01-compose2pod-extraction/plan.md b/planning/changes/2026-07-03.01-compose2pod-extraction/plan.md deleted file mode 100644 index c418cc1..0000000 --- a/planning/changes/2026-07-03.01-compose2pod-extraction/plan.md +++ /dev/null @@ -1,1446 +0,0 @@ -# compose2pod-extraction — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port the chats compose-to-podman-pod converter into the standalone public package `compose2pod`, split into focused modules, with the four review fixes, an optional YAML input path, and CI. - -**Spec:** [`design.md`](./design.md) - -**Branch:** shipped via `chore: scaffold compose2pod package` → `docs: implementation plan for the converter port` → `Port the compose-to-podman-pod converter into compose2pod (#1)`, squash-merged to `main` at `287bb86`. - -**Commit strategy:** Per-task commits (as listed in each task's final step). - -**Architecture:** A dependency-free, stdlib-only Python package (root layout, `uv_build`) that reads a Docker Compose document (dict) and emits a POSIX `sh` script running the services as a single Podman pod. `exceptions` → `healthcheck`/`graph` → `parsing`/`emit` → `cli` → `__main__`, acyclic. YAML input is an optional extra; the core reads JSON. - -**Tech Stack:** Python 3.10+, stdlib only in the core, PyYAML behind the `[yaml]` extra; uv + `uv_build`, `just`, ruff (`select=ALL`), ty, pytest. - -## Global Constraints - -- Core package has **zero runtime dependencies** (stdlib only). PyYAML is only the optional `[yaml]` extra. -- **All imports at module level** (Artur's rule) — the optional PyYAML import uses the module-level `try/except ImportError` pattern, never an in-function import. -- **Annotate every function argument.** For type-ignores use `ty: ignore`, never `type: ignore`. -- Quality gates: ruff `select=ALL` (config already in `pyproject.toml`; it **autofixes destructively** — always use `ruff check --no-fix`), ty clean, `eof-fixer` clean, `just test-ci` at **100% line coverage** (`--cov-fail-under=100`). -- The emitted script is POSIX `sh` (`set -eu`). -- Warnings/errors printed by the CLI are prefixed `compose2pod: ` (renamed from the chats prototype's `compose-to-pod: `). -- Package location: `/Users/kevinsmith/src/pypi/compose2pod`. Source of truth for behavior is the chats prototype `bin/compose_to_pod.py`; the four fixes below are the only behavior changes. -- Environment note: this repo's deps are all on public PyPI (pytest, ruff, ty, eof-fixer, PyYAML), so `just install` and `just test` run locally without the internal artifactory. -- Commit messages: conventional-commit subjects, **no `Co-authored-by` trailer**. - -## The four review fixes (folded into the ports below) - -1. **Non-dict guard** — `validate` raises `UnsupportedComposeError` when the parsed document is not a mapping (Task 3). -2. **Honor `start_period`/`retries`** — `run_flags` passes them to `podman run` as `--health-start-period`/`--health-retries`; the wait budget is unchanged (Task 4). -3. **Merge duplicate constant** — one `_CMD_MIN_LENGTH = 2` in `healthcheck.py` (Task 1). -4. **Split `emit_script`** — the target-run branch is extracted into `_emit_target` (Task 4). - -## File structure - -- `compose2pod/exceptions.py` — `UnsupportedComposeError`. -- `compose2pod/healthcheck.py` — `has_healthcheck`, `health_cmd`, `interval_seconds`. -- `compose2pod/graph.py` — `depends_on`, `hostnames`, `startup_order`. -- `compose2pod/parsing.py` — subset constants + `validate` (+ `_validate_service`, `_validate_depends_on`). -- `compose2pod/emit.py` — `EmitOptions`, `image_for`, `command_tokens`, `run_flags`, `emit_script` (+ `_emit_target`, `_run_tokens`, `_render`), `HEALTHY_WAIT_BUDGET_SECONDS`. -- `compose2pod/cli.py` — `main`, format detection, JSON/YAML loading, `POD_NAME_PATTERN`. -- `compose2pod/__main__.py` — `python -m compose2pod`. -- `compose2pod/__init__.py` — public exports. -- `tests/test_healthcheck.py`, `tests/test_graph.py`, `tests/test_parsing.py`, `tests/test_emit.py`, `tests/test_cli.py`. -- `.github/workflows/{ci,_checks,release}.yml`. - -Note: `exceptions.py` is a leaf module added beyond the spec's listed modules to keep imports acyclic (`graph`, `healthcheck`, `parsing`, `emit`, `cli` all import the error from it). It matches `modern-di`'s `exceptions.py` convention. - -A shared fixture `CHATS_COMPOSE` is duplicated only where needed; to keep tests DRY, define it once in `tests/conftest.py` as a fixture. - ---- - -### Task 0: Toolchain baseline - -**Files:** -- Create: `tests/conftest.py` -- Modify: none (scaffold `pyproject.toml`, `justfile` already exist) - -- [x] **Step 1: Install deps** - -Run: `cd /Users/kevinsmith/src/pypi/compose2pod && just install` -Expected: uv resolves from public PyPI, creates `.venv`, installs the `yaml` extra + `lint` group. No error. - -- [x] **Step 2: Add the shared fixture** - -Create `tests/conftest.py`: - -```python -import pytest - - -@pytest.fixture -def chats_compose() -> dict: - return { - "services": { - "application": { - "build": { - "context": ".", - "dockerfile": "./Dockerfile", - "args": ["ARTIFACTORY_USER=$ARTIFACTORY_USER", "ARTIFACTORY_PASSWORD=$ARTIFACTORY_PASSWORD"], - }, - "restart": "always", - "volumes": [".:/srv/www/"], - "depends_on": { - "migrations": {"condition": "service_completed_successfully"}, - "keydb": {"condition": "service_healthy"}, - }, - "env_file": "tests.env", - "stdin_open": True, - "tty": True, - "ports": ["9991:9991"], - "command": ["python", "-m", "chats.api"], - }, - "migrations": { - "build": {"context": ".", "dockerfile": "./Dockerfile", "args": []}, - "command": ["alembic", "upgrade", "head"], - "volumes": [".:/srv/www/"], - "depends_on": {"db": {"condition": "service_healthy"}}, - "env_file": "tests.env", - }, - "db": { - "image": "postgres:13.5-alpine", - "restart": "always", - "environment": ["POSTGRES_PASSWORD=password"], - "healthcheck": { - "test": ["CMD-SHELL", "pg_isready -U database -d database"], - "interval": "1s", - "timeout": "5s", - "retries": 15, - }, - "ports": ["5432:5432"], - }, - "keydb": { - "image": "keydb-for-ci:v6.3.0", - "networks": {"default": {"aliases": ["keydb-test-server-0"]}}, - "healthcheck": { - "test": ["CMD", "keydb-cli", "-p", "26379", "sentinel", "get-master-addr-by-name", "mymaster"], - "interval": "1s", - "timeout": "15s", - "retries": 5, - }, - }, - }, - } -``` - -- [x] **Step 3: Commit** - -```bash -git add tests/conftest.py -git commit -m "test: add shared chats_compose fixture" -``` - ---- - -### Task 1: exceptions + healthcheck modules - -**Files:** -- Create: `compose2pod/exceptions.py`, `compose2pod/healthcheck.py` -- Test: `tests/test_healthcheck.py` - -**Interfaces:** -- Produces: `UnsupportedComposeError(Exception)`; `has_healthcheck(svc: dict[str, Any]) -> bool`; `health_cmd(test: object) -> str | None`; `interval_seconds(duration: object) -> int`. - -- [x] **Step 1: Write the failing tests** - -Create `tests/test_healthcheck.py`: - -```python -import pytest -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.healthcheck import has_healthcheck, health_cmd, interval_seconds - - -class TestHealthCmd: - def test_cmd_shell_form(self) -> None: - assert health_cmd(["CMD-SHELL", "pg_isready -U db"]) == "pg_isready -U db" - - def test_cmd_list_form_becomes_json(self) -> None: - assert health_cmd(["CMD", "keydb-cli", "-p", "26379"]) == '["keydb-cli", "-p", "26379"]' - - def test_plain_string_passes_through(self) -> None: - assert health_cmd("true") == "true" - - def test_none_and_disable_forms(self) -> None: - assert health_cmd(None) is None - assert health_cmd(["NONE"]) is None - - def test_unknown_kind_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="WHATEVER"): - health_cmd(["WHATEVER", "x"]) - - def test_empty_list_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported healthcheck test"): - health_cmd([]) - - def test_non_list_non_string_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported healthcheck test"): - health_cmd(42) - - def test_cmd_shell_without_argument_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported healthcheck test"): - health_cmd(["CMD-SHELL"]) - - def test_cmd_without_command_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported healthcheck test"): - health_cmd(["CMD"]) - - -class TestIntervalSeconds: - def test_seconds_suffix(self) -> None: - assert interval_seconds("5s") == 5 - - def test_minutes_suffix(self) -> None: - assert interval_seconds("2m") == 120 - - def test_none_defaults_to_one(self) -> None: - assert interval_seconds(None) == 1 - - def test_milliseconds_floor_to_one(self) -> None: - assert interval_seconds("500ms") == 1 - - def test_milliseconds_above_one_second(self) -> None: - assert interval_seconds("5000ms") == 5 - - def test_int_value_passes_through(self) -> None: - assert interval_seconds(5) == 5 - - def test_float_value_below_one_floors_to_one(self) -> None: - assert interval_seconds(0.4) == 1 - - -class TestHasHealthcheck: - def test_true_when_test_present(self) -> None: - assert has_healthcheck({"healthcheck": {"test": ["CMD-SHELL", "true"]}}) is True - - def test_false_when_missing(self) -> None: - assert has_healthcheck({"image": "x"}) is False - - def test_false_when_none_test(self) -> None: - assert has_healthcheck({"healthcheck": {"test": "NONE"}}) is False -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `just test tests/test_healthcheck.py` -Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.healthcheck'`. - -- [x] **Step 3: Implement `exceptions.py`** - -```python -"""Exceptions for compose2pod.""" - - -class UnsupportedComposeError(Exception): - """Raised when the compose file uses a construct outside the supported subset.""" -``` - -- [x] **Step 4: Implement `healthcheck.py`** (fix #3: single `_CMD_MIN_LENGTH`) - -```python -"""Healthcheck translation: compose healthcheck -> podman --health-* values.""" - -import json -from typing import Any - -from compose2pod.exceptions import UnsupportedComposeError - - -_CMD_MIN_LENGTH = 2 - - -def has_healthcheck(svc: dict[str, Any]) -> bool: - """Report whether the service defines a healthcheck with a non-disabled test.""" - test = (svc.get("healthcheck") or {}).get("test") - return test is not None and test not in ("NONE", ["NONE"]) - - -def health_cmd(test: object) -> str | None: - """Compose healthcheck `test` value to a podman --health-cmd value.""" - if test is None or test in ("NONE", ["NONE"]): - return None - if isinstance(test, str): - return test - if not isinstance(test, list) or not test: - raise UnsupportedComposeError(f"unsupported healthcheck test: {test!r}") - kind = test[0] - if kind == "CMD-SHELL": - if len(test) < _CMD_MIN_LENGTH: - raise UnsupportedComposeError(f"unsupported healthcheck test: {test!r}") - return test[1] - if kind == "CMD": - if len(test) < _CMD_MIN_LENGTH: - raise UnsupportedComposeError(f"unsupported healthcheck test: {test!r}") - return json.dumps(test[1:]) - raise UnsupportedComposeError(f"unsupported healthcheck test kind: {kind!r}") - - -def interval_seconds(duration: object) -> int: - """Compose duration ('1s', '2m', '500ms', int) to whole seconds, minimum 1.""" - if duration is None: - return 1 - if isinstance(duration, (int, float)): - return max(int(duration), 1) - text = str(duration).strip() - if text.endswith("ms"): - return max(int(float(text[:-2]) / 1000), 1) - if text.endswith("m"): - return max(int(float(text[:-1])) * 60, 1) - text = text.removesuffix("s") - return max(int(float(text)), 1) -``` - -- [x] **Step 5: Run tests + lint** - -Run: `just test tests/test_healthcheck.py` → Expected: PASS (20 tests). -Run: `just lint-ci` → Expected: clean. - -- [x] **Step 6: Commit** - -```bash -git add compose2pod/exceptions.py compose2pod/healthcheck.py tests/test_healthcheck.py -git commit -m "feat: healthcheck translation and UnsupportedComposeError" -``` - ---- - -### Task 2: graph module - -**Files:** -- Create: `compose2pod/graph.py` -- Test: `tests/test_graph.py` - -**Interfaces:** -- Consumes: `UnsupportedComposeError` from `compose2pod.exceptions`. -- Produces: `depends_on(svc: dict[str, Any]) -> dict[str, str]`; `hostnames(services: dict[str, Any]) -> list[str]`; `startup_order(services: dict[str, Any], target: str) -> list[str]`. - -- [x] **Step 1: Write the failing tests** - -Create `tests/test_graph.py`: - -```python -import pytest -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.graph import depends_on, hostnames, startup_order - - -class TestDependsOn: - def test_list_form_normalizes_to_service_started(self) -> None: - assert depends_on({"depends_on": ["db"]}) == {"db": "service_started"} - - def test_map_form_keeps_conditions(self) -> None: - assert depends_on({"depends_on": {"db": {"condition": "service_healthy"}}}) == {"db": "service_healthy"} - - def test_missing_depends_on_is_empty(self) -> None: - assert depends_on({"image": "x"}) == {} - - -class TestHostnames: - def test_collects_service_names_and_aliases(self, chats_compose: dict) -> None: - assert hostnames(chats_compose["services"]) == [ - "application", "migrations", "db", "keydb", "keydb-test-server-0", - ] - - def test_non_dict_network_entry_is_skipped(self) -> None: - services = {"app": {"image": "x", "networks": {"default": None, "other": {"aliases": ["app-alias"]}}}} - assert hostnames(services) == ["app", "app-alias"] - - -class TestStartupOrder: - def test_chats_order(self, chats_compose: dict) -> None: - order = startup_order(chats_compose["services"], "application") - assert order[-1] == "application" - assert order.index("db") < order.index("migrations") < order.index("application") - assert order.index("keydb") < order.index("application") - assert set(order) == {"db", "migrations", "keydb", "application"} - - def test_services_outside_target_closure_are_excluded(self) -> None: - assert startup_order({"app": {"image": "x"}, "unrelated": {"image": "y"}}, "app") == ["app"] - - def test_unknown_target_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="nope"): - startup_order({"app": {"image": "x"}}, "nope") - - def test_unknown_dependency_raises(self) -> None: - services = {"app": {"image": "x", "depends_on": {"ghost": {"condition": "service_started"}}}} - with pytest.raises(UnsupportedComposeError, match="ghost"): - startup_order(services, "app") - - def test_dependency_cycle_raises(self) -> None: - services = { - "a": {"image": "x", "depends_on": {"b": {"condition": "service_started"}}}, - "b": {"image": "x", "depends_on": {"a": {"condition": "service_started"}}}, - } - with pytest.raises(UnsupportedComposeError, match="cycle"): - startup_order(services, "a") - - def test_diamond_dependency_visits_shared_service_once(self) -> None: - services = { - "c": {"image": "x"}, - "a": {"image": "x", "depends_on": {"c": {"condition": "service_started"}}}, - "b": {"image": "x", "depends_on": {"c": {"condition": "service_started"}}}, - "target": {"image": "x", "depends_on": { - "a": {"condition": "service_started"}, "b": {"condition": "service_started"}}}, - } - order = startup_order(services, "target") - assert order.count("c") == 1 - assert order.index("c") < order.index("a") - assert order.index("c") < order.index("b") - assert order[-1] == "target" -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `just test tests/test_graph.py` -Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.graph'`. - -- [x] **Step 3: Implement `graph.py`** - -```python -"""Dependency graph: normalize depends_on, collect hostnames, compute startup order.""" - -from typing import Any - -from compose2pod.exceptions import UnsupportedComposeError - - -def depends_on(svc: dict[str, Any]) -> dict[str, str]: - """Normalize dependencies of a service to a name -> condition mapping.""" - deps = svc.get("depends_on") or {} - if isinstance(deps, list): - return dict.fromkeys(deps, "service_started") - return {name: spec.get("condition", "service_started") for name, spec in deps.items()} - - -def hostnames(services: dict[str, Any]) -> list[str]: - """All names other services may use to reach a service: names, then aliases.""" - names = list(services) - for svc in services.values(): - networks = svc.get("networks") - if isinstance(networks, dict): - for network in networks.values(): - if isinstance(network, dict): - names.extend(network.get("aliases") or []) - return names - - -def startup_order(services: dict[str, Any], target: str) -> list[str]: - """Dependency closure of target in start order (dependencies first, target last).""" - if target not in services: - raise UnsupportedComposeError(f"target service '{target}' not found") - order: list[str] = [] - state: dict[str, str] = {} - - def visit(name: str) -> None: - if state.get(name) == "visiting": - raise UnsupportedComposeError(f"dependency cycle involving '{name}'") - if state.get(name) == "done": - return - if name not in services: - raise UnsupportedComposeError(f"unknown dependency '{name}'") - state[name] = "visiting" - for dep in depends_on(services[name]): - visit(dep) - state[name] = "done" - order.append(name) - - visit(target) - return order -``` - -- [x] **Step 4: Run tests + lint** - -Run: `just test tests/test_graph.py` → Expected: PASS. -Run: `just lint-ci` → Expected: clean. - -- [x] **Step 5: Commit** - -```bash -git add compose2pod/graph.py tests/test_graph.py -git commit -m "feat: dependency graph and startup order" -``` - ---- - -### Task 3: parsing module (fix #1: non-dict guard) - -**Files:** -- Create: `compose2pod/parsing.py` -- Test: `tests/test_parsing.py` - -**Interfaces:** -- Consumes: `UnsupportedComposeError` (exceptions), `depends_on` (graph), `has_healthcheck` (healthcheck). -- Produces: `validate(compose: dict[str, Any]) -> list[str]`; module constants `SUPPORTED_SERVICE_KEYS`, `IGNORED_SERVICE_KEYS`, `SUPPORTED_HEALTHCHECK_KEYS`, `SUPPORTED_TOP_LEVEL_KEYS`, `DEPENDS_ON_CONDITIONS`. - -- [x] **Step 1: Write the failing tests** (ports the chats `TestValidate` + a new non-dict test) - -Create `tests/test_parsing.py`: - -```python -import pytest -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.parsing import validate - - -class TestValidate: - def test_chats_compose_is_accepted_with_warnings_for_ignored_keys(self, chats_compose: dict) -> None: - joined = "\n".join(validate(chats_compose)) - assert "'ports'" in joined - assert "'restart'" in joined - assert "'stdin_open'" in joined - assert "'tty'" in joined - - def test_non_dict_document_raises(self) -> None: - for bad in (None, [], "compose", 42): - with pytest.raises(UnsupportedComposeError, match="must be a mapping"): - validate(bad) # ty: ignore[invalid-argument-type] - - def test_unsupported_service_key_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="privileged"): - validate({"services": {"app": {"image": "x", "privileged": True}}}) - - def test_unsupported_healthcheck_key_raises(self) -> None: - compose = {"services": {"app": {"image": "x", "healthcheck": {"test": "true", "start_interval": "1s"}}}} - with pytest.raises(UnsupportedComposeError, match="start_interval"): - validate(compose) - - def test_named_volume_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="pgdata"): - validate({"services": {"db": {"image": "x", "volumes": ["pgdata:/var/lib/postgresql/data"]}}}) - - def test_long_volume_syntax_raises(self) -> None: - compose = {"services": {"app": {"image": "x", "volumes": [{"type": "bind", "source": ".", "target": "/s"}]}}} - with pytest.raises(UnsupportedComposeError, match="short volume syntax"): - validate(compose) - - def test_no_services_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="no services"): - validate({"services": {}}) - - def test_unknown_top_level_key_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secrets"): - validate({"services": {"app": {"image": "x"}}, "secrets": {}}) - - def test_top_level_networks_is_ignored_with_warning(self) -> None: - warnings = validate({"services": {"app": {"image": "x"}}, "networks": {"default": None}}) - assert any("networks" in w for w in warnings) - - def test_service_healthy_dependency_without_healthcheck_raises(self) -> None: - compose = {"services": { - "migrations": {"image": "x", "depends_on": {"db": {"condition": "service_healthy"}}}, - "db": {"image": "y"}}} - with pytest.raises(UnsupportedComposeError, - match=r"depends on 'db' \(service_healthy\) but 'db' has no healthcheck"): - validate(compose) - - def test_service_healthy_dependency_with_none_test_raises(self) -> None: - compose = {"services": { - "migrations": {"image": "x", "depends_on": {"db": {"condition": "service_healthy"}}}, - "db": {"image": "y", "healthcheck": {"test": "NONE"}}}} - with pytest.raises(UnsupportedComposeError, match=r"depends on 'db' \(service_healthy\)"): - validate(compose) - - def test_service_healthy_dependency_with_healthcheck_is_accepted(self) -> None: - compose = {"services": { - "migrations": {"image": "x", "depends_on": {"db": {"condition": "service_healthy"}}}, - "db": {"image": "y", "healthcheck": {"test": ["CMD-SHELL", "true"]}}}} - assert validate(compose) == [] - - def test_service_healthy_dependency_on_unknown_service_is_out_of_scope(self) -> None: - assert validate( - {"services": {"app": {"image": "x", "depends_on": {"ghost": {"condition": "service_healthy"}}}}} - ) == [] - - def test_unknown_depends_on_condition_raises(self) -> None: - compose = {"services": { - "app": {"image": "x", "depends_on": {"db": {"condition": "service_ready"}}}, - "db": {"image": "y"}}} - with pytest.raises(UnsupportedComposeError, - match=r"service 'app': depends_on 'db' has unsupported condition 'service_ready'"): - validate(compose) -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `just test tests/test_parsing.py` -Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.parsing'`. - -- [x] **Step 3: Implement `parsing.py`** (fix #1 is the `isinstance` guard at the top of `validate`) - -```python -"""Validate a compose document against the supported subset.""" - -from typing import Any - -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.graph import depends_on -from compose2pod.healthcheck import has_healthcheck - - -SUPPORTED_SERVICE_KEYS = { - "image", "build", "command", "environment", "env_file", - "volumes", "healthcheck", "depends_on", "networks", -} -IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty"} -SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} -SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks"} -DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"} - - -def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: - """Validate one service; returns warnings, raises UnsupportedComposeError.""" - warnings: list[str] = [] - for key in sorted(svc): - if key in IGNORED_SERVICE_KEYS: - warnings.append(f"service {name!r}: ignoring '{key}'") - elif key not in SUPPORTED_SERVICE_KEYS: - raise UnsupportedComposeError(f"service {name!r}: unsupported key '{key}'") - for key in sorted(svc.get("healthcheck") or {}): - if key not in SUPPORTED_HEALTHCHECK_KEYS: - raise UnsupportedComposeError(f"service {name!r}: unsupported healthcheck key '{key}'") - for volume in svc.get("volumes") or []: - if not isinstance(volume, str): - raise UnsupportedComposeError(f"service {name!r}: only short volume syntax is supported") - source = volume.split(":", 1)[0] - if not source.startswith((".", "/")): - raise UnsupportedComposeError( - f"service {name!r}: named volume '{source}' is not supported (bind mounts only)" - ) - return warnings - - -def _validate_depends_on(services: dict[str, Any]) -> None: - """Cross-service depends_on checks: known conditions, service_healthy needs a healthcheck.""" - for name, svc in services.items(): - for dep, condition in depends_on(svc).items(): - if condition not in DEPENDS_ON_CONDITIONS: - raise UnsupportedComposeError( - f"service {name!r}: depends_on {dep!r} has unsupported condition {condition!r}" - ) - if condition == "service_healthy" and dep in services and not has_healthcheck(services[dep]): - raise UnsupportedComposeError( - f"service {name!r}: depends on {dep!r} (service_healthy) but {dep!r} has no healthcheck" - ) - - -def validate(compose: dict[str, Any]) -> list[str]: - """Check the compose document against the supported subset. - - Returns human-readable warnings for ignored constructs. - Raises UnsupportedComposeError for anything that would change behavior silently. - """ - if not isinstance(compose, dict): - raise UnsupportedComposeError(f"compose document must be a mapping, got {type(compose).__name__}") - warnings: list[str] = [] - unknown_top = set(compose) - SUPPORTED_TOP_LEVEL_KEYS - if unknown_top: - raise UnsupportedComposeError(f"unsupported top-level keys: {sorted(unknown_top)}") - if "networks" in compose: - warnings.append("ignoring top-level 'networks' (all services share the pod namespace)") - services = compose.get("services") or {} - if not services: - raise UnsupportedComposeError("no services defined") - for name, svc in services.items(): - warnings.extend(_validate_service(name, svc)) - _validate_depends_on(services) - return warnings -``` - -- [x] **Step 4: Run tests + lint** - -Run: `just test tests/test_parsing.py` → Expected: PASS. -Run: `just lint-ci` → Expected: clean. - -- [x] **Step 5: Commit** - -```bash -git add compose2pod/parsing.py tests/test_parsing.py -git commit -m "feat: compose subset validation with non-dict guard" -``` - ---- - -### Task 4: emit module (fix #2: start_period/retries; fix #4: split emit_script) - -**Files:** -- Create: `compose2pod/emit.py` -- Test: `tests/test_emit.py` - -**Interfaces:** -- Consumes: `depends_on`, `hostnames`, `startup_order` (graph); `health_cmd`, `interval_seconds` (healthcheck). -- Produces: `EmitOptions` (frozen dataclass: `target, ci_image, command, pod, project_dir, artifacts: list[str], allow_exit_codes: list[int]`); `image_for(svc, ci_image) -> str`; `command_tokens(svc) -> list[str]`; `run_flags(name, svc, pod, hosts, project_dir) -> list[str]`; `emit_script(compose, options: EmitOptions) -> str`; `HEALTHY_WAIT_BUDGET_SECONDS = 120`. - -**Design note (for reviewer/Artur):** fix #2 implements the spec's refined decision — `start_period`/`retries` are passed to `podman run` as `--health-start-period`/`--health-retries`; the `wait_healthy` budget is unchanged (still `HEALTHY_WAIT_BUDGET_SECONDS // interval` attempts). This deliberately does not shorten the wait to `retries × interval`, which would risk premature failure for a long `start_period`. - -- [x] **Step 1: Write the failing tests** (ports `TestRunFlags`, `TestImageAndCommand`, `TestEmitScript`, plus fix-#2 cases) - -Create `tests/test_emit.py`: - -```python -from compose2pod.emit import ( - EmitOptions, command_tokens, emit_script, image_for, run_flags, -) - - -class TestRunFlags: - def test_db_flags(self, chats_compose: dict) -> None: - flags = run_flags("db", chats_compose["services"]["db"], "test-pod", ["db", "keydb"], "/builds/chats") - assert flags[:4] == ["--pod", "test-pod", "--name", "test-pod-db"] - assert flags[4:6] == ["--add-host", "db:127.0.0.1"] - assert flags[6:8] == ["--add-host", "keydb:127.0.0.1"] - assert flags[8:10] == ["-e", "POSTGRES_PASSWORD=password"] - assert flags[10:12] == ["--health-cmd", "pg_isready -U database -d database"] - assert flags[12:14] == ["--health-timeout", "5s"] - assert flags[14:16] == ["--health-retries", "15"] # fix #2 - - def test_start_period_is_passed_through(self) -> None: - svc = {"image": "x", "healthcheck": {"test": "true", "start_period": "30s"}} - flags = run_flags("app", svc, "p", [], "/b") - assert "--health-start-period" in flags - assert flags[flags.index("--health-start-period") + 1] == "30s" - - def test_env_map_form(self) -> None: - svc = {"image": "x", "environment": {"A": "1", "B": "two words"}} - flags = run_flags("app", svc, "p", [], "/builds/x") - assert flags[4:6] == ["-e", "A=1"] - assert flags[6:8] == ["-e", "B=two words"] - - def test_env_file_and_volume_resolved_against_project_dir(self) -> None: - svc = {"image": "x", "env_file": "tests.env", "volumes": [".:/srv/www/"]} - flags = run_flags("app", svc, "p", [], "/builds/chats") - assert flags[4:6] == ["--env-file", "/builds/chats/tests.env"] - assert flags[6:8] == ["-v", "/builds/chats:/srv/www/"] - - def test_env_file_list_form(self) -> None: - svc = {"image": "x", "env_file": ["a.env", "b.env"]} - flags = run_flags("app", svc, "p", [], "/builds/x") - assert flags[4:8] == ["--env-file", "/builds/x/a.env", "--env-file", "/builds/x/b.env"] - - def test_absolute_volume_source_is_kept_as_is(self) -> None: - flags = run_flags("app", {"image": "x", "volumes": ["/data/app:/srv/www/"]}, "p", [], "/builds/x") - assert flags[4:6] == ["-v", "/data/app:/srv/www/"] - - def test_healthcheck_without_timeout_omits_health_timeout_flag(self) -> None: - flags = run_flags("app", {"image": "x", "healthcheck": {"test": "true"}}, "p", [], "/builds/x") - assert flags[4:6] == ["--health-cmd", "true"] - assert "--health-timeout" not in flags - - -class TestImageAndCommand: - def test_build_service_uses_ci_image(self, chats_compose: dict) -> None: - assert image_for(chats_compose["services"]["application"], "reg/ci:abc") == "reg/ci:abc" - - def test_plain_service_keeps_image(self, chats_compose: dict) -> None: - assert image_for(chats_compose["services"]["db"], "reg/ci:abc") == "postgres:13.5-alpine" - - def test_command_list_passes_through(self, chats_compose: dict) -> None: - assert command_tokens(chats_compose["services"]["migrations"]) == ["alembic", "upgrade", "head"] - - def test_command_string_becomes_shell(self) -> None: - assert command_tokens({"command": "echo hi"}) == ["/bin/sh", "-c", "echo hi"] - - def test_missing_command_is_empty(self) -> None: - assert command_tokens({"image": "x"}) == [] - - -class TestEmitScript: - def make_script(self, chats_compose: dict) -> str: - options = EmitOptions( - target="application", - ci_image="reg/app/ci:abc1234", - command="pytest . -n 4 --junitxml=/srv/out/junit.xml", - pod="test-pod", - project_dir="/builds/chats", - artifacts=["/srv/out/junit.xml:junit.xml", "/srv/out/coverage.xml:coverage.xml"], - allow_exit_codes=[5], - ) - return emit_script(compose=chats_compose, options=options) - - def test_pod_lifecycle(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - assert "podman pod create --name test-pod" in script - assert "trap 'podman pod rm -f test-pod" in script - - def test_dependencies_start_before_target_and_waits_are_placed(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - run_db = script.index("--name test-pod-db") - wait_db = script.index("wait_healthy test-pod-db") - run_migrations = script.index("--name test-pod-migrations") - wait_keydb = script.index("wait_healthy test-pod-keydb") - run_application = script.index("--name test-pod-application") - assert run_db < wait_db < run_migrations < run_application - assert wait_keydb < run_application - - def test_db_and_keydb_detached_migrations_foreground(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - for line in script.splitlines(): - if "--name test-pod-db" in line: - assert line.startswith("podman run -d ") - if "--name test-pod-keydb" in line: - assert line.startswith("podman run -d ") - if "--name test-pod-migrations" in line: - assert line.startswith("podman run --rm ") - assert line.rstrip().endswith("alembic upgrade head") - - def test_target_command_is_overridden_and_rc_gated(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - assert "pytest . -n 4 --junitxml=/srv/out/junit.xml" in script.replace("'", "") - assert "|| rc=$?" in script - assert "0|5)" in script - - def test_artifacts_copied_before_rc_gate(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - cp_junit = script.index("podman cp test-pod-application:/srv/out/junit.xml junit.xml") - gate = script.index('case "$rc" in') - assert cp_junit < gate - - def test_failure_branch_prints_oom_diagnostics(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - gate = script.index('case "$rc" in') - assert "OOMKilled={{.State.OOMKilled}}" in script[gate:] - assert "podman ps -a" in script[gate:] - - def test_add_host_on_every_run(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - run_lines = [line for line in script.splitlines() if line.startswith("podman run")] - assert len(run_lines) == 4 - for line in run_lines: - assert "--add-host keydb-test-server-0:127.0.0.1" in line - - def test_wait_healthy_function_uses_healthcheck_run(self, chats_compose: dict) -> None: - script = self.make_script(chats_compose) - assert "podman healthcheck run" in script - assert "wait_healthy()" in script - - def test_target_without_command_uses_service_command(self, chats_compose: dict) -> None: - options = EmitOptions( - target="application", ci_image="reg/ci:abc", command="", - pod="test-pod", project_dir="/b", artifacts=[], allow_exit_codes=[], - ) - script = emit_script(compose=chats_compose, options=options) - target_line = next(line for line in script.splitlines() if "--name test-pod-application" in line) - assert target_line.rstrip().endswith("python -m chats.api") -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `just test tests/test_emit.py` -Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.emit'`. - -- [x] **Step 3: Implement `emit.py`** (fix #2 in `run_flags`; fix #4 splits `_emit_target`/`_run_tokens`/`_render`) - -```python -"""Render the podman-pod test script for a target service and its dependencies.""" - -import dataclasses -import shlex -from pathlib import Path -from typing import Any - -from compose2pod.graph import depends_on, hostnames, startup_order -from compose2pod.healthcheck import health_cmd, interval_seconds - - -HEALTHY_WAIT_BUDGET_SECONDS = 120 - - -def image_for(svc: dict[str, Any], ci_image: str) -> str: - """Services with a build section run the freshly built CI image.""" - if "build" in svc: - return ci_image - return svc["image"] - - -def command_tokens(svc: dict[str, Any]) -> list[str]: - """Service command as argv tokens; compose string form means shell form.""" - command = svc.get("command") - if command is None: - return [] - if isinstance(command, str): - return ["/bin/sh", "-c", command] - return list(command) - - -def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[str]: - """Flag tokens (unquoted) for `podman run` of one service.""" - flags = ["--pod", pod, "--name", f"{pod}-{name}"] - for host in hosts: - flags += ["--add-host", f"{host}:127.0.0.1"] - environment = svc.get("environment") or {} - pairs = environment if isinstance(environment, list) else [f"{k}={v}" for k, v in environment.items()] - for pair in pairs: - flags += ["-e", pair] - env_files = svc.get("env_file") or [] - if isinstance(env_files, str): - env_files = [env_files] - for env_file in env_files: - flags += ["--env-file", str(Path(project_dir, env_file))] - for volume in svc.get("volumes") or []: - source, destination = volume.split(":", 1) - if not source.startswith("/"): - source = str(Path(project_dir, source)) - flags += ["-v", f"{source}:{destination}"] - healthcheck = svc.get("healthcheck") or {} - cmd = health_cmd(healthcheck.get("test")) - if cmd is not None: - flags += ["--health-cmd", cmd] - if "timeout" in healthcheck: - flags += ["--health-timeout", str(healthcheck["timeout"])] - if "start_period" in healthcheck: - flags += ["--health-start-period", str(healthcheck["start_period"])] - if "retries" in healthcheck: - flags += ["--health-retries", str(healthcheck["retries"])] - return flags - - -_SCRIPT_HEADER = """\ -#!/bin/sh -# Generated by compose2pod -- do not edit, regenerate instead. -set -eu - -wait_healthy() { - ctr=$1 - attempts=$2 - interval=$3 - i=0 - while [ "$i" -lt "$attempts" ]; do - if podman healthcheck run "$ctr"; then - return 0 - fi - i=$((i + 1)) - sleep "$interval" - done - echo "wait_healthy: $ctr did not become healthy after $attempts attempts" >&2 - podman logs "$ctr" >&2 || true - return 1 -} -""" - - -@dataclasses.dataclass(frozen=True) -class EmitOptions: - """Options for emit_script rendering.""" - - target: str - ci_image: str - command: str - pod: str - project_dir: str - artifacts: list[str] - allow_exit_codes: list[int] - - -def _render(tokens: list[str]) -> str: - return " ".join(shlex.quote(token) for token in tokens) - - -def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[str]: - svc = services[name] - tokens = run_flags(name, svc, options.pod, hosts, options.project_dir) - tokens.append(image_for(svc, options.ci_image)) - if name == options.target and options.command: - tokens.extend(shlex.split(options.command)) - else: - tokens.extend(command_tokens(svc)) - return tokens - - -def _emit_target(lines: list[str], run_tokens: list[str], options: EmitOptions) -> None: - target_ctr = shlex.quote(f"{options.pod}-{options.target}") - lines.append("rc=0") - lines.append(f"podman run {_render(run_tokens)} || rc=$?") - for artifact in options.artifacts: - source, destination = artifact.split(":", 1) - lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true") - allowed = "|".join(str(code) for code in [0, *options.allow_exit_codes]) - lines.append('case "$rc" in') - lines.append(f" {allowed}) ;;") - lines.append(' *) echo "target service failed with exit code $rc" >&2') - lines.append( - " podman inspect --format " - "'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' " - + target_ctr - + " >&2 || true" - ) - lines.append(" podman ps -a --format '{{.Names}} {{.Status}}' >&2 || true") - lines.append(' exit "$rc" ;;') - lines.append("esac") - - -def emit_script(compose: dict[str, Any], options: EmitOptions) -> str: - """Render the full pod test script for `target` and its dependency closure.""" - services = compose["services"] - hosts = hostnames(services) - order = startup_order(services, options.target) - completion_gated = { - dep - for svc in services.values() - for dep, condition in depends_on(svc).items() - if condition == "service_completed_successfully" - } - - lines = [_SCRIPT_HEADER] - lines.append(f"trap 'podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true' EXIT") - lines.append(f"podman pod create --name {shlex.quote(options.pod)}") - waited: set[str] = set() - for name in order: - for dep, condition in depends_on(services[name]).items(): - if condition == "service_healthy" and dep not in waited: - interval = interval_seconds((services[dep].get("healthcheck") or {}).get("interval")) - attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1) - lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}") - waited.add(dep) - run_tokens = _run_tokens(name, services, options, hosts) - if name == options.target: - _emit_target(lines, run_tokens, options) - elif name in completion_gated: - lines.append(f"podman run --rm {_render(run_tokens)}") - else: - lines.append(f"podman run -d {_render(run_tokens)}") - return "\n".join(lines) + "\n" -``` - -- [x] **Step 4: Run tests + lint** - -Run: `just test tests/test_emit.py` → Expected: PASS. -Run: `just lint-ci` → Expected: clean. - -- [x] **Step 5: Commit** - -```bash -git add compose2pod/emit.py tests/test_emit.py -git commit -m "feat: pod script emission with start_period/retries pass-through" -``` - ---- - -### Task 5: cli + __main__ + public API (YAML extra, --format) - -**Files:** -- Create: `compose2pod/cli.py`, `compose2pod/__main__.py` -- Modify: `compose2pod/__init__.py` -- Test: `tests/test_cli.py` - -**Interfaces:** -- Consumes: `validate` (parsing), `EmitOptions`/`emit_script` (emit), `UnsupportedComposeError` (exceptions). -- Produces: `main(argv: list[str] | None = None) -> int`; `POD_NAME_PATTERN`; package exports `validate`, `emit_script`, `EmitOptions`, `UnsupportedComposeError`. - -- [x] **Step 1: Write the failing tests** - -Create `tests/test_cli.py`: - -```python -import io -import json -import subprocess -import sys - -import pytest -import compose2pod.cli as cli -from compose2pod.cli import main - - -def run_main(compose_text: str, argv: list[str], monkeypatch: pytest.MonkeyPatch) -> int: - monkeypatch.setattr(sys, "stdin", io.StringIO(compose_text)) - return main(argv) - - -class TestPublicApi: - def test_exports(self) -> None: - import compose2pod - assert set(compose2pod.__all__) == {"EmitOptions", "UnsupportedComposeError", "emit_script", "validate"} - assert compose2pod.validate({"services": {"a": {"image": "x"}}}) == [] - - -class TestMain: - def test_json_stdin_success_with_warnings(self, chats_compose: dict, - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main(json.dumps(chats_compose), - ["--target", "application", "--image", "reg/ci:abc", - "--project-dir", "/builds/chats", "--command", "pytest .", - "--artifact", "/srv/out/junit.xml:junit.xml", "--allow-exit-code", "5"], - monkeypatch) - out = capsys.readouterr() - assert rc == 0 - assert out.out.startswith("#!/bin/sh") - assert "podman pod create" in out.out - assert "compose2pod:" in out.err - - def test_yaml_stdin_success(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - yaml_text = "services:\n app:\n image: x\n" - rc = run_main(yaml_text, ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch) - out = capsys.readouterr() - assert rc == 0 - assert "podman pod create" in out.out - - def test_auto_falls_back_to_yaml(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main("services:\n app:\n image: x\n", ["--target", "app", "--image", "i"], monkeypatch) - assert rc == 0 - assert "podman pod create" in capsys.readouterr().out - - def test_yaml_without_pyyaml_errors(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(cli, "_yaml", None) - rc = run_main("services: {}", ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch) - assert rc == 2 - assert "requires the 'yaml' extra" in capsys.readouterr().err - - def test_invalid_yaml_returns_2(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main("a: [1, 2", ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch) - assert rc == 2 - assert "invalid YAML" in capsys.readouterr().err - - def test_malformed_json_returns_2(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main("not json", ["--target", "app", "--image", "i", "--format", "json"], monkeypatch) - assert rc == 2 - assert "could not parse" in capsys.readouterr().err - - def test_non_mapping_document_returns_2(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main("42", ["--target", "app", "--image", "i", "--format", "json"], monkeypatch) - assert rc == 2 - assert "must be a mapping" in capsys.readouterr().err - - def test_unsupported_compose_returns_2(self, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main(json.dumps({"services": {"app": {"image": "x", "privileged": True}}}), - ["--target", "app", "--image", "i"], monkeypatch) - assert rc == 2 - assert "privileged" in capsys.readouterr().err - - def test_invalid_pod_name_returns_2(self, chats_compose: dict, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch) -> None: - rc = run_main(json.dumps(chats_compose), - ["--target", "application", "--image", "i", "--pod-name", "bad name"], monkeypatch) - assert rc == 2 - assert "invalid pod name" in capsys.readouterr().err - - def test_file_argument_is_read(self, tmp_path, chats_compose: dict, - capsys: pytest.CaptureFixture[str]) -> None: - compose_file = tmp_path / "docker-compose.json" - compose_file.write_text(json.dumps(chats_compose)) - rc = main([str(compose_file), "--target", "application", "--image", "i"]) - assert rc == 0 - assert "podman pod create" in capsys.readouterr().out - - -class TestModuleEntrypoint: - def test_python_m_runs(self, chats_compose: dict) -> None: - # S603: fixed interpreter + module + test args, not external input. - result = subprocess.run( # noqa: S603 - [sys.executable, "-m", "compose2pod", "--target", "application", "--image", "i"], - input=json.dumps(chats_compose), capture_output=True, text=True, check=False, - ) - assert result.returncode == 0, result.stderr - assert result.stdout.startswith("#!/bin/sh") -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `just test tests/test_cli.py` -Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.cli'`. - -- [x] **Step 3: Implement `cli.py`** (module-level optional yaml import; `# noqa: ANN401` where returning parsed data) - -```python -"""Command-line interface: read a compose document and emit the pod script.""" - -import argparse -import json -import re -import sys -from pathlib import Path -from typing import Any - -from compose2pod.emit import EmitOptions, emit_script -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.parsing import validate - - -try: - import yaml as _yaml -except ImportError: # pragma: no cover - the optional [yaml] extra is not installed - _yaml = None - - -POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") - - -def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data - if _yaml is None: - raise UnsupportedComposeError( - "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)" - ) - try: - return _yaml.safe_load(text) - except _yaml.YAMLError as error: - raise UnsupportedComposeError(f"invalid YAML: {error}") from error - - -def _read_compose(text: str, fmt: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data - if fmt == "json": - return json.loads(text) - if fmt == "yaml": - return _load_yaml(text) - try: - return json.loads(text) - except json.JSONDecodeError: - return _load_yaml(text) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - prog="compose2pod", - description="Convert a Docker Compose document to a podman-pod run script (stdout).", - ) - parser.add_argument("file", nargs="?", help="compose file to read (default: stdin)") - parser.add_argument("--target", required=True, help="service to run in the foreground with --command") - parser.add_argument("--image", required=True, help="CI image replacing services that have a build section") - parser.add_argument("--project-dir", default=".", help="host path relative volume/env_file sources resolve to") - parser.add_argument("--command", default="", help="shell command overriding the target service command") - parser.add_argument("--pod-name", default="test-pod") - parser.add_argument("--format", choices=("auto", "json", "yaml"), default="auto") - parser.add_argument("--artifact", action="append", default=[], metavar="SRC:DST", - help="file to podman-cp out of the target container after it exits") - parser.add_argument("--allow-exit-code", type=int, action="append", default=[], - help="target exit code treated as success in addition to 0") - args = parser.parse_args(argv) - if not POD_NAME_PATTERN.match(args.pod_name): - sys.stderr.write(f"compose2pod: error: invalid pod name {args.pod_name!r}\n") - return 2 - text = Path(args.file).read_text() if args.file else sys.stdin.read() - try: - compose = _read_compose(text, args.format) - except (json.JSONDecodeError, UnsupportedComposeError) as error: - sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n") - return 2 - try: - warnings = validate(compose) - script = emit_script( - compose=compose, - options=EmitOptions( - target=args.target, - ci_image=args.image, - command=args.command, - pod=args.pod_name, - project_dir=args.project_dir, - artifacts=args.artifact, - allow_exit_codes=args.allow_exit_code, - ), - ) - except UnsupportedComposeError as error: - sys.stderr.write(f"compose2pod: error: {error}\n") - return 2 - for warning in warnings: - sys.stderr.write(f"compose2pod: {warning}\n") - sys.stdout.write(script) - return 0 -``` - -Note: the non-mapping case (`42`, `"not json"` under `auto`) reaches `validate`, which raises `UnsupportedComposeError` (fix #1) → caught in the second `try` → exit 2. The `test_non_mapping_document_returns_2` test asserts that path. - -- [x] **Step 4: Implement `__main__.py`** - -```python -"""python -m compose2pod entry point.""" - -import sys - -from compose2pod.cli import main - - -if __name__ == "__main__": # pragma: no cover - exercised via subprocess in tests - sys.exit(main()) -``` - -- [x] **Step 5: Implement `__init__.py` exports** - -```python -"""compose2pod: convert a Docker Compose file into a single-Podman-pod run script.""" - -from compose2pod.emit import EmitOptions, emit_script -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.parsing import validate - - -__all__ = [ - "EmitOptions", - "UnsupportedComposeError", - "emit_script", - "validate", -] -``` - -- [x] **Step 6: Run tests + lint + full coverage** - -Run: `just test tests/test_cli.py` → Expected: PASS. -Run: `just test-ci` → Expected: PASS at 100% line coverage. -Run: `just lint-ci` → Expected: clean. - -- [x] **Step 7: Commit** - -```bash -git add compose2pod/cli.py compose2pod/__main__.py compose2pod/__init__.py tests/test_cli.py -git commit -m "feat: CLI with JSON/YAML input and public API exports" -``` - ---- - -### Task 6: CI workflows - -**Files:** -- Create: `.github/workflows/ci.yml`, `.github/workflows/_checks.yml`, `.github/workflows/release.yml` - -**Interfaces:** none (CI only). Mirrors `modern-di`, minus the docs and planning-bundle jobs. - -- [x] **Step 1: Create `.github/workflows/ci.yml`** - -```yaml -name: main -on: - push: - branches: - - main - pull_request: {} - -concurrency: - group: ${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - checks: - uses: ./.github/workflows/_checks.yml -``` - -- [x] **Step 2: Create `.github/workflows/_checks.yml`** - -```yaml -name: checks -on: - workflow_call: {} - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v8.2.0 - with: - enable-cache: true - cache-dependency-glob: "**/pyproject.toml" - - run: uv python install 3.10 - - run: uv python pin 3.10 - - run: just install lint-ci - - pytest: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - steps: - - uses: actions/checkout@v6 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v8.2.0 - with: - enable-cache: true - cache-dependency-glob: "**/pyproject.toml" - - run: uv python install ${{ matrix.python-version }} - - run: uv python pin ${{ matrix.python-version }} - - run: just install - - run: just test-ci -``` - -- [x] **Step 3: Create `.github/workflows/release.yml`** - -```yaml -name: Release - -# Tag-driven: pushing a semver tag publishes to PyPI (Trusted Publishing) and -# creates the matching GitHub Release with auto-generated notes. -on: - push: - tags: - - '[0-9]+.[0-9]+.[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+' - -permissions: - contents: write - id-token: write - -jobs: - release: - runs-on: ubuntu-latest - environment: pypi - steps: - - uses: actions/checkout@v6 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v7 - - run: just publish - - name: Resolve prerelease flag - id: meta - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then - echo "prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "prerelease=false" >> "$GITHUB_OUTPUT" - fi - - name: Publish GitHub Release - uses: softprops/action-gh-release@v3 - with: - generate_release_notes: true - prerelease: ${{ steps.meta.outputs.prerelease }} - draft: false -``` - -- [x] **Step 4: Validate workflow YAML** - -Run: `python3 -c "import yaml,glob; [yaml.safe_load(open(f)) for f in glob.glob('.github/workflows/*.yml')]; print('ok')"` -Expected: `ok`. - -- [x] **Step 5: Final full-suite gate** - -Run: `just test-ci` → Expected: PASS at 100% line coverage. -Run: `just lint-ci` → Expected: clean. - -- [x] **Step 6: Commit** - -```bash -git add .github/workflows/ -git commit -m "ci: lint + pytest matrix and tag-driven PyPI trusted publishing" -``` - -**Manual follow-up (outside this plan, Artur):** create the GitHub repo `modern-python/compose2pod`, push, and register the PyPI Trusted Publisher (project `compose2pod`, workflow `release.yml`, environment `pypi`) before the first tag. - ---- - -## Self-Review - -**Spec coverage:** niche/why (README + docstrings ✓); supported subset (Task 3 constants + tests ✓); emitted-script behavior (Task 4 ✓); CLI incl. `--format`, file arg, pod-name validation (Task 5 ✓); public API (Task 5 `__init__` ✓); parsing approach — stdlib core + optional yaml (Task 5 module-level try/except ✓); root layout / uv_build (scaffold ✓, unchanged); four fixes — #1 Task 3, #2 Task 4, #3 Task 1, #4 Task 4 ✓; tooling/CI (Task 6 ✓); testing 100% line (Task 5 Step 6, Task 6 Step 5 ✓). Out-of-scope items (chats migration, pypelines wiring) correctly absent. - -**Placeholder scan:** no TBD/TODO; every code step contains complete code; every test step contains real assertions. - -**Type consistency:** `EmitOptions` fields identical across Task 4 definition and Task 5 usage; `validate`/`emit_script`/`health_cmd`/`interval_seconds`/`run_flags`/`depends_on`/`hostnames`/`startup_order` signatures identical between producing task, tests, and importers; module import graph is acyclic (`exceptions` ← `healthcheck`/`graph` ← `parsing`/`emit` ← `cli` ← `__main__`). - -## Notes for the executor - -- Coverage is line-based (`--cov-fail-under=100`), matching modern-di. `# pragma: no cover` is used on the optional-yaml `except ImportError` (PyYAML is installed via `--all-extras`, so that line never runs in CI) and on the `__main__` guard. -- Never run bare `ruff check` in this repo — the config autofixes destructively. Use `just lint-ci` (`ruff check --no-fix`). -- Commit messages: conventional-commit subjects, no `Co-authored-by` trailer. - -## Status - -All tasks shipped: `72422be` (scaffold), `048231e` (this plan, as `docs/superpowers/plans/2026-07-03-compose2pod-extraction.md`), `287bb86` (`#1`, squash-merged to `main`). This bundle is the planning-convention home for that history; see [`design.md`](./design.md) for the spec it implements. diff --git a/planning/changes/2026-07-04.01-extension-fields/design.md b/planning/changes/2026-07-04.01-extension-fields.md similarity index 100% rename from planning/changes/2026-07-04.01-extension-fields/design.md rename to planning/changes/2026-07-04.01-extension-fields.md diff --git a/planning/changes/2026-07-04.01-extension-fields/plan.md b/planning/changes/2026-07-04.01-extension-fields/plan.md deleted file mode 100644 index de5c8f1..0000000 --- a/planning/changes/2026-07-04.01-extension-fields/plan.md +++ /dev/null @@ -1,310 +0,0 @@ -# extension-fields — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Accept Compose `x-` extension fields at every level `validate()` -inspects, silently, so anchor-based compose files convert instead of erroring. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/extension-fields` (already created; the spec commit lives here). - -**Commit strategy:** Per-task commits, conventional-commit subjects, **no** -`Co-authored-by` trailer (project rule). - -## Global Constraints - -Copied verbatim from `CLAUDE.md` — every task must respect these: - -- Core package has **zero runtime dependencies** (stdlib only); PyYAML is only - the optional `[yaml]` extra. This change adds no imports. -- All imports at module level. Annotate every function argument. -- Use `ty: ignore`, never `type: ignore`. -- `just lint-ci` must pass clean. **Never run bare `ruff check`** — the repo - config autofixes destructively. Use `just lint` (safe) or `just lint-ci`. -- `just test-ci` must pass at **100% line coverage** (`--cov-fail-under=100`). -- `just check-planning` must pass before pushing. -- The `x-` prefix match is lowercase exactly (`key.startswith("x-")`), as the - Compose spec mandates. - ---- - -### Task 1: Accept `x-` extension fields in `validate()` - -**Files:** -- Modify: `compose2pod/parsing.py` (three key-validation loops) -- Test: `tests/test_parsing.py` (add to `TestValidate`) -- Test: `tests/test_cli.py` (add end-to-end anchor test to `TestMain`) - -Teach the three key-inspection loops in `validate()` to skip any `x-`-prefixed -key silently, and prove it end to end through the YAML/anchor pipeline. - -- [ ] **Step 1: Write the failing unit tests** - - Add these three methods to the `TestValidate` class in - `tests/test_parsing.py`: - - ```python - def test_top_level_extension_key_is_accepted(self) -> None: - compose = {"x-application-defaults": {"build": {}}, "services": {"app": {"image": "x"}}} - assert validate(compose) == [] - - def test_service_extension_key_is_accepted_silently(self) -> None: - warnings = validate({"services": {"app": {"image": "x", "x-labels": {"team": "a"}}}}) - assert warnings == [] - - def test_healthcheck_extension_key_is_accepted(self) -> None: - compose = {"services": {"app": {"image": "x", "healthcheck": {"test": "true", "x-note": "n"}}}} - assert validate(compose) == [] - ``` - -- [ ] **Step 2: Run the unit tests to verify they fail** - - Run: `uv run --no-sync pytest tests/test_parsing.py -k extension -v` - Expected: 3 FAILs — top-level raises `unsupported top-level keys: - ['x-application-defaults']`, service raises `unsupported key 'x-labels'`, - healthcheck raises `unsupported healthcheck key 'x-note'`. - -- [ ] **Step 3: Implement the three skips in `compose2pod/parsing.py`** - - In `_validate_service`, add an `x-` skip at the top of the service-key loop - (before the `IGNORED_SERVICE_KEYS` branch): - - ```python - for key in sorted(svc): - if key.startswith("x-"): - continue - if key in IGNORED_SERVICE_KEYS: - warnings.append(f"service {name!r}: ignoring '{key}'") - elif key not in SUPPORTED_SERVICE_KEYS: - msg = f"service {name!r}: unsupported key '{key}'" - raise UnsupportedComposeError(msg) - ``` - - In the same function, add an `x-` skip to the healthcheck-key loop: - - ```python - for key in sorted(svc.get("healthcheck") or {}): - if key.startswith("x-"): - continue - if key not in SUPPORTED_HEALTHCHECK_KEYS: - msg = f"service {name!r}: unsupported healthcheck key '{key}'" - raise UnsupportedComposeError(msg) - ``` - - In `validate`, replace the top-level unknown-key computation (the - `unknown_top = set(compose) - SUPPORTED_TOP_LEVEL_KEYS` line) with a - comprehension that also drops `x-` keys: - - ```python - unknown_top = {k for k in compose if k not in SUPPORTED_TOP_LEVEL_KEYS and not k.startswith("x-")} - ``` - -- [ ] **Step 4: Run the unit tests to verify they pass** - - Run: `uv run --no-sync pytest tests/test_parsing.py -k extension -v` - Expected: 3 PASS. - -- [ ] **Step 5: Write the failing end-to-end anchor test** - - Add this method to the `TestMain` class in `tests/test_cli.py` (exercises a - top-level `x-` anchor block, a `<<:` merge, and a service-level `x-` key - through the real `_read_compose` → `validate` → `emit_script` pipeline): - - ```python - def test_yaml_anchor_extension_fields_convert( - self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch - ) -> None: - yaml_text = ( - "x-defaults: &defaults\n" - " image: base:latest\n" - "services:\n" - " app:\n" - " <<: *defaults\n" - " x-meta: keep\n" - ) - rc = run_main(yaml_text, ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch) - out = capsys.readouterr() - assert rc == 0 - assert "podman pod create" in out.out - ``` - -- [ ] **Step 6: Run the end-to-end test to verify it passes** - - Run: `uv run --no-sync pytest tests/test_cli.py -k anchor_extension -v` - Expected: PASS (implementation from Step 3 already makes it green; this test - guards the full pipeline, not just `validate()`). - -- [ ] **Step 7: Run the full suite at 100% coverage** - - Run: `just test-ci` - Expected: all pass, coverage 100%. If any new branch is uncovered, the run - fails — the three tests above each hit one of the three new `x-` skips. - -- [ ] **Step 8: Lint** - - Run: `just lint-ci` - Expected: clean. (If it flags formatting, run `just lint` to autofix safely, - then re-run `just lint-ci`.) - -- [ ] **Step 9: Commit** - - ```bash - git add compose2pod/parsing.py tests/test_parsing.py tests/test_cli.py - git commit -m "feat: accept compose x- extension fields" - ``` - ---- - -### Task 2: Document the supported subset - -**Files:** -- Create: `architecture/supported-subset.md` -- Modify: `README.md` (the "Supported compose subset" section) - -Pin the accept/ignore/reject rules — including the new `x-` rule — into the -living architecture doc, and point the README at it. - -- [ ] **Step 1: Create `architecture/supported-subset.md`** - - Write this file exactly (no frontmatter — living prose, per the - `architecture/` convention): - - ```markdown - # Supported compose subset - - compose2pod converts an honest subset of Docker Compose and refuses the rest - loudly rather than silently dropping behavior. `validate()` - (`compose2pod/parsing.py`) is the gate: anything it does not recognize either - warns (ignored, behavior-neutral inside a single pod) or raises - `UnsupportedComposeError`. - - ## Top-level keys - - - **Supported:** `services` (required, non-empty), `version`, `name`, - `networks`. - - **Ignored (warns):** `networks` — all services share the pod's single - network namespace, so top-level network definitions have no effect. - - **Extension fields:** any key prefixed `x-` is accepted and ignored - silently, per the Compose spec. This is what lets a document hold shared - config in a top-level `x-*` block for reuse via YAML anchors. - - Everything else raises. - - ## Service keys - - - **Supported:** `image`, `build`, `command`, `environment`, `env_file`, - `volumes`, `healthcheck`, `depends_on`, `networks`. - - **Ignored (warns):** `ports`, `restart`, `stdin_open`, `tty` — meaningless - or irrelevant inside a single shared-namespace pod. - - **Extension fields:** any `x-`-prefixed service key is accepted and ignored - silently. - - Everything else raises. - - ## Healthcheck keys - - - **Supported:** `test`, `interval`, `timeout`, `retries`, `start_period`. - - **Extension fields:** any `x-`-prefixed healthcheck key is accepted and - ignored silently. - - Everything else raises. - - ## Volumes - - Short bind-mount syntax only (`source:target`). The source must be a host path - (starts with `.` or `/`); named volumes and the long mapping form raise. - - ## depends_on - - All three conditions are honored: `service_started`, `service_healthy`, - `service_completed_successfully`. A `service_healthy` dependency on a service - with no usable healthcheck raises. - - ## YAML anchors and merge keys - - Anchors (`&name` / `*name`) and the merge key (`<<:`) need no handling in - compose2pod: PyYAML's `safe_load` resolves them at load time, so `validate()` - and `emit` see already-merged service mappings. JSON input has no anchors but - can still carry literal `x-` extension keys, handled identically. - ``` - -- [ ] **Step 2: Update the README subset section** - - In `README.md`, replace the paragraph under `## Supported compose subset` - (the one starting "compose2pod supports an honest subset...") with: - - ```markdown - compose2pod supports an honest subset and errors clearly on anything outside - it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind - `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and - network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML - anchors are accepted as-is, so a top-level `x-*` anchor block for shared - config just works. See `architecture/supported-subset.md` for the full - accept/ignore/reject matrix. - ``` - -- [ ] **Step 3: Verify the eof-fixer / formatting on docs** - - Run: `just lint-ci` - Expected: clean (eof-fixer requires a trailing newline on both files). - -- [ ] **Step 4: Commit** - - ```bash - git add architecture/supported-subset.md README.md - git commit -m "docs: document supported subset and x- extension fields" - ``` - ---- - -### Task 3: Finalize the change bundle and run all gates - -**Files:** -- Modify: `planning/changes/2026-07-04.01-extension-fields/design.md` (finalize - `summary:` if wording needs it) - -Confirm the whole change is green and the planning bundle is ship-ready. - -- [ ] **Step 1: Confirm the `design.md` summary reads as the realized result** - - Open `planning/changes/2026-07-04.01-extension-fields/design.md`. The - `summary:` line should state what shipped: - `Accept Compose x- extension fields at every validated level, ignoring them - silently.` Adjust only if the implementation diverged; otherwise leave it. - -- [ ] **Step 2: Run the full gate set** - - ```bash - just lint-ci - just test-ci - just check-planning - ``` - Expected: all three succeed; coverage 100%; `planning: OK`. - -- [ ] **Step 3: Commit any bundle edit (skip if nothing changed)** - - ```bash - git add planning/changes/2026-07-04.01-extension-fields/design.md - git commit -m "docs: finalize extension-fields change summary" - ``` - -- [ ] **Step 4: Hand off to finishing-a-development-branch** - - Push `feat/extension-fields` and open a PR (never local-merge, per project - workflow). Use the `superpowers:finishing-a-development-branch` skill to do - this and watch CI. - ---- - -## Self-review notes - -- **Spec coverage:** Design §1 (three-level `x-` skip) → Task 1 Steps 1-4; - silent decision → Task 1 Step 1 `test_service_extension_key_is_accepted_silently` - (`warnings == []`); end-to-end anchor round-trip → Task 1 Steps 5-6; Design §2 - (seed `architecture/supported-subset.md`) → Task 2 Step 1; README note → Task 2 - Step 2; testing/coverage → Task 1 Step 7. -- **No placeholders:** every code and doc step shows full content. -- **Type/name consistency:** `validate()` signature and `UnsupportedComposeError` - messages match `parsing.py` exactly; test helper `run_main` and fixtures match - `tests/test_cli.py`. diff --git a/planning/index.py b/planning/index.py index f2ea3e6..2d70ac3 100644 --- a/planning/index.py +++ b/planning/index.py @@ -1,13 +1,12 @@ # ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) """Generate the planning index from frontmatter. -Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's -``design.md``, falling back to ``change.md``) and ``planning/decisions/*.md``, -reads their frontmatter, and prints a Markdown listing to stdout — changes -then decisions, newest-first. Never writes a file: +Run via ``just index``. Globs ``planning/changes/*.md`` and +``planning/decisions/*.md``, reads their frontmatter, and prints a Markdown +listing to stdout — changes then decisions, newest-first. Never writes a file: the listing is a query over the files, not a committed artifact. -``date`` and ``slug`` are derived from the directory / file name, not +``date`` and ``slug`` are derived from the file name, not frontmatter — the name is the single source of truth for both. """ @@ -18,9 +17,8 @@ ROOT = pathlib.Path(__file__).parent VALID_DECISION_STATUS = {"accepted", "superseded"} -BUNDLE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") +CHANGE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") -ALLOWED_BUNDLE_FILES = {"design.md", "plan.md", "change.md"} SPEC_REQUIRED = ("summary",) DECISION_REQUIRED = ("status", "summary") @@ -45,7 +43,7 @@ def parse_frontmatter(text: str) -> dict[str, str]: def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: - """Inject ``date``/``slug`` derived from a dir/file name into ``fields``.""" + """Inject ``date``/``slug`` derived from a file name into ``fields``.""" match = pattern.match(name) if match: fields["date"] = match.group("date") @@ -53,25 +51,20 @@ def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[ return fields -def load_bundles(root: pathlib.Path) -> list[dict[str, str]]: - """Read each bundle's summary; derive date/slug from the directory name.""" +def load_changes(root: pathlib.Path) -> list[dict[str, str]]: + """Read each change file's summary; derive date/slug from the file name.""" changes_dir = root / "changes" - bundles: list[dict[str, str]] = [] + changes: list[dict[str, str]] = [] if not changes_dir.is_dir(): - return bundles - for bundle in sorted(changes_dir.iterdir()): - if not bundle.is_dir(): + return changes + for path in sorted(changes_dir.glob("*.md")): + if path.name == "README.md" or path.name.startswith(("_", ".")): continue - spec = bundle / "design.md" - if not spec.exists(): - spec = bundle / "change.md" - if not spec.exists(): - continue - fields = _named(parse_frontmatter(spec.read_text(encoding="utf-8")), bundle.name, BUNDLE_RE) - fields["path"] = f"changes/{bundle.name}/{spec.name}" - fields["name"] = bundle.name - bundles.append(fields) - return bundles + fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, CHANGE_RE) + fields["path"] = f"changes/{path.name}" + fields["name"] = path.stem + changes.append(fields) + return changes def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: @@ -90,24 +83,24 @@ def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: return decisions -def format_row(bundle: dict[str, str]) -> str: - """Render one bundle as a Markdown list item.""" - slug = bundle.get("slug", "?") - path = bundle.get("path", "") - date = bundle.get("date", "") - summary = bundle.get("summary") or "(no summary)" +def format_row(row: dict[str, str]) -> str: + """Render one change or decision as a Markdown list item.""" + slug = row.get("slug", "?") + path = row.get("path", "") + date = row.get("date", "") + summary = row.get("summary") or "(no summary)" line = f"- **[{slug}]({path})** ({date}) — {summary}" - if bundle.get("supersedes"): - line += f" _(supersedes {bundle['supersedes']})_" - if bundle.get("superseded_by"): - line += f" _(superseded by {bundle['superseded_by']})_" + if row.get("supersedes"): + line += f" _(supersedes {row['supersedes']})_" + if row.get("superseded_by"): + line += f" _(superseded by {row['superseded_by']})_" return line -def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: +def render(changes: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: """Render the full Markdown listing: changes then decisions, newest-first.""" out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] - change_rows = sorted(bundles, key=lambda b: b.get("name", ""), reverse=True) + change_rows = sorted(changes, key=lambda b: b.get("name", ""), reverse=True) out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] out += ["", "## Decisions", ""] decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) @@ -121,32 +114,15 @@ def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) -def _check_spec_file(path: pathlib.Path, rel: str, violations: list[str]) -> None: - """Validate a design.md / change.md spec file (requires `summary`).""" +def _check_change(path: pathlib.Path, violations: list[str]) -> None: + """Validate one change file (requires `summary`).""" + rel = f"changes/{path.name}" + if CHANGE_RE.match(path.stem) is None: + violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'") fields = parse_frontmatter(path.read_text(encoding="utf-8")) _require(fields, SPEC_REQUIRED, rel, violations) -def _check_bundle(bundle: pathlib.Path, violations: list[str]) -> None: - """Validate one change bundle directory.""" - rel = f"changes/{bundle.name}" - if BUNDLE_RE.match(bundle.name) is None: - violations.append(f"{rel}: directory name is not 'YYYY-MM-DD.NN-slug'") - violations.extend( - f"{rel}/{child.name}: unexpected file in bundle (allowed: {', '.join(sorted(ALLOWED_BUNDLE_FILES))})" - for child in sorted(bundle.iterdir()) - if child.name not in ALLOWED_BUNDLE_FILES - ) - design = bundle / "design.md" - change = bundle / "change.md" - if not design.exists() and not change.exists(): - violations.append(f"{rel}: bundle has neither design.md nor change.md") - for spec_file in (design, change): - if spec_file.exists(): - _check_spec_file(spec_file, f"{rel}/{spec_file.name}", violations) - # plan.md carries no frontmatter — its identity comes from the bundle dir. - - def _check_decision(path: pathlib.Path, violations: list[str]) -> None: """Validate one decision file (requires `status` + `summary`).""" rel = f"decisions/{path.name}" @@ -160,14 +136,24 @@ def _check_decision(path: pathlib.Path, violations: list[str]) -> None: def check(root: pathlib.Path) -> list[str]: - """Validate every bundle and decision; return the list of violation strings.""" + """Validate every change file and decision; return the list of violation strings.""" violations: list[str] = [] changes_dir = root / "changes" decisions_dir = root / "decisions" if changes_dir.is_dir(): - for bundle in sorted(changes_dir.iterdir()): - if bundle.is_dir(): - _check_bundle(bundle, violations) + for path in sorted(changes_dir.iterdir()): + if path.is_dir(): + violations.append( + f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files " + f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)" + ) + continue + if path.name == "README.md" or path.name.startswith(("_", ".")): + continue + if path.suffix != ".md": + violations.append(f"changes/{path.name}: unexpected non-md file in changes/") + else: + _check_change(path, violations) if decisions_dir.is_dir(): for path in sorted(decisions_dir.glob("*.md")): if path.name == "README.md" or path.name.startswith("_"): @@ -177,7 +163,7 @@ def check(root: pathlib.Path) -> list[str]: def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Print the listing to stdout, or validate bundles with --check.""" + """Print the listing to stdout, or validate change files and decisions with --check.""" argv = sys.argv[1:] if argv is None else argv root = ROOT if root is None else root if "--check" in argv: @@ -189,7 +175,7 @@ def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int return 1 sys.stdout.write("planning: OK\n") return 0 - sys.stdout.write(render(load_bundles(root), load_decisions(root))) + sys.stdout.write(render(load_changes(root), load_decisions(root))) return 0 From b02b1682ccb55405d3c53dcc63c3e61f07b05e34 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 12:19:09 +0300 Subject: [PATCH 2/2] docs: retire "change bundle" wording in living-truth prose --- architecture/README.md | 2 +- planning/deferred.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/architecture/README.md b/architecture/README.md index 36b8f6f..fd32fce 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -9,7 +9,7 @@ Files here carry no frontmatter; they are living prose, dated by git. A change **promotes** its conclusions into the affected `architecture/.md` by hand, in the same PR as the code — the edit rides in the same diff and is reviewed with it, never applied as a separate -post-merge step. The bundle in `planning/changes/` stays as the *why*; +post-merge step. The change file in `planning/changes/` stays as the *why*; `architecture/` stays as the *what, now*. Capability files and `architecture/glossary.md` are authored lazily — created diff --git a/planning/deferred.md b/planning/deferred.md index 80d390a..b1d8bf0 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -14,4 +14,4 @@ unsafe trap command. **Revisit trigger:** a library-API test is added that exercises `emit_script` with adversarial `pod` values (quotes, spaces, shell metacharacters), or a bug report surfaces from a caller using the library path without CLI validation. -Needs its own change bundle. +Needs its own change file.