From aec09cdeda5676ad4f6a5be3cac2eefb3f7090ab Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 18:58:58 +0000 Subject: [PATCH 01/28] docs: design spec for hm.deploy + hm dev local deployments Driver-agnostic top-level decorator (@hm.deploy) + driver-specific factory (hm.dev.deploy) so future cloud drivers slot in without touching the registry. v1 ships local Docker only via `hm dev up`, foreground, with per-session bridge networks and OS-assigned host ports so multiple worktrees can coexist. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-21-hm-dev-deploy-design.md | 663 ++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md diff --git a/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md b/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md new file mode 100644 index 0000000..f665206 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md @@ -0,0 +1,663 @@ +# `hm.deploy` + `hm dev` Local Deployments — Design Spec + +**Status:** Draft. v1 = local Docker driver only. +**Repos touched:** `harmont-py` (DSL), `harmont-cli` (executor). +**Authors:** Claude + Marko. + +--- + +## Goal + +Let Harmont users declare long-lived, port-mapped local services (Postgres, Redis, an API container, a webapp dev server, …) from the same Python DSL they use for pipelines, and bring them up with one foreground command per worktree. + +## Motivation + +The agentic workflow has one developer (or one agent) per git worktree. Each worktree wants its own Postgres, its own API, its own dev server — running on **globally-unique host ports** so they coexist on one machine. Today this requires hand-curated `docker compose` files with manually-assigned ports, drifting from the canonical CI definition. + +`hm.deploy` + `hm dev up` makes that ergonomic, type-checked, and consistent with the rest of Harmont: + +- Same DSL idioms as `@hm.pipeline` and `@hm.target` (fixture injection, frozen dataclasses, decoration-time validation, fix-directed errors). +- One source of truth: deployments live alongside pipelines in `.harmont/*.py`. +- Each `hm dev up` invocation gets its own ephemeral docker network and a per-session container suffix so multiple sessions in the same worktree don't collide. +- Port assignment is delegated to the OS (`docker -p :CPORT`), so no global registry, no allocator, no lock files. + +## Non-goals (v1) + +- Non-local drivers (`hm.aws`, `hm.fly`, `hm.k8s`). The decorator + abstract type are designed to admit them later; no stubs ship now. +- Daemon-mode / background deployments. +- Healthchecks beyond `Running: true`. +- Cross-session shared state. +- Persistent named volumes (bind mounts only). +- Pipeline ↔ deployment auto-wiring (a test pipeline can't yet declare "needs db up"); deferred. +- Wire-format JSON IR for deployments. v1 hands a Python dict (serialized JSON) from a subprocess to the CLI. Formalized when a second driver lands. + +--- + +## §1 DSL surface + +### Decoupling test + +If `harmont.dev` were deleted entirely, `harmont.deploy` + `harmont.Dep` + `harmont.Deployment` would still compile, the registry would still populate (with deployments nobody can materialize), and `hm dev up` would error cleanly: "no local driver available." That asymmetry is the invariant — top-level is driver-agnostic; everything driver-specific lives in `harmont.dev`. + +### Public surface (full enumeration) + +```python +# harmont/__init__.py — top-level (driver-agnostic) +hm.deploy(slug=None, *, name=None) # decorator +hm.Dep[T] # PEP-593 marker for dep injection +hm.Deployment # abstract dataclass; .name + .driver + +# harmont/dev/__init__.py — local driver +hm.dev.deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) # -> LocalDeployment +hm.dev.port() # sentinel: OS picks free host port +hm.dev.LocalDeployment # concrete subclass of Deployment +hm.dev.dump_registry_json() # -> str (driver-filtered for local) +``` + +### Canonical example + +```python +import harmont as hm + +@hm.target() +def api_image() -> hm.Step: + return hm.sh("docker build -t myapi .", image="docker:24") + +@hm.deploy("db") +def db() -> hm.Deployment: + return hm.dev.deploy( + image="postgres:16", + cmd=["postgres", "-c", "shared_buffers=128MB"], + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + +@hm.deploy("api") +def api( + db: hm.Dep[hm.Deployment], + api_image: hm.Target[hm.Step], +) -> hm.Deployment: + return hm.dev.deploy( + from_=api_image, + port_mapping={8000: hm.dev.port()}, + env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, + volumes={".": "/workspace"}, + workdir="/workspace", + ) +``` + +### Type hierarchy + +```python +# harmont/_deploy.py +@dataclass(frozen=True) +class Deployment: + name: str + driver: str # discriminator: "local" in v1 + +# harmont/dev/_deployment.py +@dataclass(frozen=True) +class LocalDeployment(Deployment): + image: str | None + from_step: Step | None + cmd: tuple[str, ...] | None + port_mapping: Mapping[int, _PortSentinel] + env: Mapping[str, str] + volumes: Mapping[str, str] + workdir: str | None + # __post_init__ enforces driver == "local" +``` + +### Fixture injection (parameters) + +A `@hm.deploy`-decorated function's parameters carry typed markers, just like `@hm.target`: + +- `hm.Dep[hm.Deployment]` — declares a dependency on another `@hm.deploy` by parameter name. The injected value is a `Deployment` with `.name` already resolved (the slug). The decorator builds the dep graph from these markers. +- `hm.Target[T]` — same `@hm.target` machinery already used by pipelines. + +Rules (decoration-time): + +- Every parameter must carry a marker or have a default; otherwise `ValueError` at decoration time, fix-directed message. +- `*args` / `**kwargs` / positional-only params are rejected. +- Duplicate slugs across `@hm.deploy` raise at decoration time (`hm.deploy(name="…")` is the disambiguation hatch — mirrors `@hm.target`). +- Dep cycles raise `RuntimeError` listing the path. + +### Validation rules for `hm.dev.deploy(...)` + +- Exactly one of `image=` and `from_=` must be set; else `ValueError`. +- `port_mapping` keys are ints in `[1, 65535]`; values must be `hm.dev.port()` sentinels in v1 (pinned-int values are future). Wrong type → `ValueError` w/ pointer to `hm.dev.port()`. +- `volumes` keys are host paths (relative resolved against worktree root, absolute kept as-is); values are container paths. Container paths must start with `/`. +- `cmd` must be a sequence of strings; coerced to `tuple[str, ...]`. +- `env` values must be strings; non-strings rejected at decoration time (avoid `str(int)` surprises mid-run). +- Slug must match `^[a-z][a-z0-9-]{0,30}$` (Docker container name rules, lowercased). + +### `hm.dev.port()` sentinel + +Returns a singleton `_PortSentinel` instance. It is `==` to itself, has no public attributes, and its `__repr__` is ``. It is **only** valid as a value in `port_mapping`. Used anywhere else (env value, cmd arg, …) → `ValueError` at the point of use with a fix-directed message: + +``` +hm.dev.port() can only appear as a port_mapping value, not as an env value. + → use a fixed value here, or query the resolved port via + `hm dev port-of ` after `hm dev up`. +``` + +### Registry handoff (python → cli) + +`harmont.dev.dump_registry_json()` walks `.harmont/*.py`, runs decorators, filters the registry to local-driver deployments, and emits: + +```json +{ + "schema_version": "0", + "worktree": "/home/marko/myrepo", + "deployments": { + "db": { + "driver": "local", + "image": "postgres:16", + "from": null, + "cmd": ["postgres", "-c", "shared_buffers=128MB"], + "port_mapping": {"5432": "__hm_dev_port__"}, + "env": {"POSTGRES_PASSWORD": "dev"}, + "volumes": {}, + "workdir": null, + "deps": [] + }, + "api": { + "driver": "local", + "image": null, + "from": { "type": "step_chain", "pipeline_v0": { "version": "0", "steps": [/* ... */] } }, + "cmd": null, + "port_mapping": {"8000": "__hm_dev_port__"}, + "env": {"DATABASE_URL": "postgres://db:5432/app"}, + "volumes": {".": "/workspace"}, + "workdir": "/workspace", + "deps": ["db"] + }, + "prod-api": { "driver": "aws", "_unhandled": true } + } +} +``` + +Non-local-driver deployments are emitted with `"_unhandled": true` and opaque-otherwise so `hm dev ls` can show them. + +A separate `python -m harmont.dev --dump-registry` shim emits this to stdout. The CLI shells out to it. + +--- + +## §2 CLI surface + +### Container, network, and label scheme + +``` +session-id = 6 random hex chars, generated per `hm dev up` invocation +worktree-hash = sha1(canonical_path(git rev-parse --show-toplevel))[:10] +container = hm--- +network = hm-- +labels = harmont.worktree= + harmont.slug= + harmont.session= + harmont.driver=local +``` + +Multiple `hm dev up`s in the same worktree are allowed — each gets its own session and its own bridge network. Sessions never see each other's containers. + +If git is unavailable (no repo), worktree-hash falls back to sha1 of the absolute `cwd`. The CLI never refuses to run for lack of a git repo. + +### Subcommand tree + +``` +hm dev up [SLUG ...] foreground; blocks until SIGINT + --no-deps skip transitive deps + --rebuild force image rebuild on Step-chain deployments + +hm dev down [SLUG ...] sweep this worktree's sessions + --session sweep one specific session entirely + --all sweep system-wide (every harmont.driver=local container) + +hm dev ls list registered + running deployments + +hm dev logs [--follow] tail running container's logs from another terminal + --session disambiguate when ≥2 sessions hold the slug + +hm dev port-of print host port for live deployment (designed for $()) + --session disambiguate + +hm dev exec [-- CMD ...] one-shot exec into live container; default `sh -l` + --session disambiguate +``` + +### `hm dev up` UX + +``` +$ hm dev up +[hm] session 7a2f91. resolving deployments in .harmont/ +[hm] graph: db → api → web (3 deployments, 2 edges) +[hm] network hm-a1b2c3d4e5-7a2f91: created +[db] pulling postgres:16… +[db] ready ( hm-a1b2c3d4e5-db-7a2f91 | localhost:42173 → :5432 ) +[api] building from target api_image… +[api] ready ( hm-a1b2c3d4e5-api-7a2f91 | localhost:42174 → :8000 ) +[web] ready ( hm-a1b2c3d4e5-web-7a2f91 | localhost:42175 → :3000 ) +[hm] all up. Ctrl-C to tear down. Logs follow. +[db] 2026-05-21 12:00:00 UTC LOG: database system is ready to accept connections +[api] [info] listening on :8000 +[web] [HMR] connected +^C +[hm] tearing down… +[web] stopped +[api] stopped +[db] stopped +[hm] network hm-a1b2c3d4e5-7a2f91: removed +$ +``` + +**Log mux:** each slug gets a stable color from a fixed 6-ANSI palette (cycle by `hash(slug) % 6`). Prefix is `[slug] ` in slug's color; raw line follows uncolored. Slug-width padded to longest registered slug for vertical alignment. Honor `--no-color` and `NO_COLOR` env (per `clig.dev`). + +**Boot order:** topological. Each level boots in parallel; readiness is `docker inspect` reports `Running: true`. No log-grep or port-probe healthcheck in v1. + +### Ambiguity rule (port-of / logs / exec) + +When ≥2 live sessions in the current worktree hold the requested slug, error and enumerate: + +``` +$ hm dev port-of db 5432 +hm: slug `db` matches multiple live sessions in this worktree: + 7a2f91 started 12:00:14 localhost:42173 + c4d8e0 started 12:05:31 localhost:42891 +pass `--session ` or run `hm dev ls`. +exit 5 +``` + +No silent "pick latest" — explicit per PRINCIPLES § 5. + +### `hm dev ls` output + +``` +$ hm dev ls +SLUG DRIVER SESSION STATUS PORTS +db local 7a2f91 running localhost:42173 → :5432 +api local 7a2f91 running localhost:42174 → :8000 +db local c4d8e0 running localhost:42891 → :5432 +web local — registered (not running) +prod-api aws — registered (no local driver; use `hm aws up`) +``` + +Status comes from `docker inspect` filtered by `label=harmont.worktree=`. The `prod-api` row is rendered from the registry dump's `_unhandled: true` rows. + +### Exit codes (per PRINCIPLES § 4) + +``` +0 success +1 deployment-level failure (build chain failed, container failed to start) +2 usage error (clap parse) +3 auth (unused in v1, reserved) +4 slug known but not running (port-of / logs / exec on a stopped slug) +5 API/network error (docker daemon unreachable, slug unknown, ambiguous slug) +10 cancelled +130 SIGINT +``` + +--- + +## §3 Runtime & executor + +### Process model + +One `hm dev up` invocation is one Rust process, tokio-based, that: + +1. Shells out to `python -m harmont.dev --dump-registry` to get the deployment registry JSON. +2. Computes the boot plan (topo sort, optionally pruned by `--no-deps`). +3. Creates the per-session bridge network. +4. Boots deployments level-by-level (parallel within a level). +5. Multiplexes logs from all containers to stdout until SIGINT/SIGTERM. +6. Tears down: stop, remove containers, remove network. + +### Registry handoff + +``` +hm dev up ──exec──► python -m harmont.dev --dump-registry + │ + ▼ (stdout: JSON per §1 schema) + parse via serde +``` + +Rust types live in `crates/hm/src/commands/dev/registry.rs`: + +```rust +#[derive(Debug, Deserialize)] +struct DevRegistry { + schema_version: String, + worktree: String, + deployments: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "driver")] +enum RegEntry { + #[serde(rename = "local")] Local(LocalSpec), + #[serde(other)] Unhandled, +} +``` + +`Unhandled` entries flow through to `hm dev ls` and are skipped by `hm dev up`. + +### Boot pipeline (per deployment) + +For each `D` in topo order: + +1. **Resolve image** + - `D.image` set → `docker_client.image_exists(tag)`; pull if absent. + - `D.from_step` set → run the embedded v0 IR pipeline via the existing `orchestrator::run_pipeline_local` codepath in a one-shot build container; on success `docker_client.commit_container(id, "hm-build--:")`. If a tag with that `` already exists and `--rebuild` not set, skip the build. +2. **Translate volumes** — host paths resolved against worktree root; emit bind-mounts `":[:ro]"`. +3. **Translate port mapping** — for each `{cport: __hm_dev_port__}`, emit `PortBinding{HostPort: ""}` for `cport/tcp` → daemon assigns ephemeral host port. +4. **Start container** via new `docker_client::start_service(spec)`: + + ```rust + pub struct ServiceSpec<'a> { + pub image: &'a str, + pub name: &'a str, + pub env: Vec, + pub cmd: Option>, + pub workdir: Option<&'a str>, + pub binds: Vec, + pub publish: Vec, + pub network: &'a str, + pub network_alias: &'a str, + pub labels: HashMap, + } + pub async fn start_service(&self, spec: ServiceSpec<'_>) -> Result; + ``` + +5. **Inspect for assigned ports** — `inspect_ports(container_id) -> HashMap` (container → host). Stored in an in-memory `Session`. +6. **Start log stream** — `bollard::container::logs(id, LogsOptions{follow: true, …})` → `mpsc::UnboundedSender` consumed by the log mux task. + +### Log mux + +`crates/hm/src/commands/dev/logmux.rs`: + +```rust +struct LogLine { slug: String, stream: Stream, bytes: Vec } +``` + +Per-slug `LinesReader` buffers partial chunks (docker streams may not be line-aligned), flushes on `\n`. Output format: `[] \n`. Stderr lines interleave with stdout in arrival order; v1 does not separate streams. + +### Concurrency model + +```rust +async fn up(args: UpArgs) -> Result<()> { + let reg = registry::dump(&ctx).await?; + let plan = topo::plan(®, &args)?; + let docker = DockerClient::connect()?; + let session_id = rand_hex(6); + let net = network::create(&docker, &ctx, &session_id).await?; + let mut session = Session::new(session_id, net.clone()); + + let (log_tx, log_rx) = mpsc::unbounded_channel(); + let mut sig = signal::ctrl_c_then_term(); + + for level in plan.levels() { + let mut joinset = JoinSet::new(); + for slug in level { + let spec = build_spec(®[slug], &ctx, &session, &net); + joinset.spawn(boot_one(docker.clone(), spec, log_tx.clone())); + } + while let Some(res) = joinset.join_next().await { + session.record(res??); + } + } + + tokio::spawn(logmux::run(log_rx, args.no_color)); + eprintln!("[hm] all up. Ctrl-C to tear down."); + + tokio::select! { + _ = sig.recv() => {} + _ = monitor_unexpected_exits(&docker, &session) => {} + } + + teardown(&docker, &session).await +} +``` + +`monitor_unexpected_exits` polls `docker inspect` for each container at 2s intervals; on a transition to non-running it logs `[slug] exited (code N)` and marks the entry. It does not unilaterally tear down — user might want to keep inspecting the live ones. + +### Build-chain reuse (`from_=Step`) + +Existing `orchestrator/` already executes v0 IR pipelines locally. The build path calls into the same codepath with two differences: + +| Step | `hm run` mode | `hm dev up` build mode | +|---|---|---| +| Final action | report run result | `commit_container` → tag `hm-build--:` | +| Stdout target | user-facing run UI | log mux as `[slug build]` lines | +| Cleanup | always rm one-shot container | rm one-shot container; tagged image survives | + +Single new function: + +```rust +pub async fn build_image_from_pipeline( + docker: &DockerClient, + pipeline_v0_ir: &PipelineV0, + image_tag: &str, + ctx: &Context, +) -> Result<()>; +``` + +### Field semantics summary + +| Field | Resolution | +|---|---| +| `cmd=["pg", "-c", "x"]` | Bollard `Config.cmd` — overrides image's CMD; entrypoint kept. | +| `env={"K": "V"}` | Bollard `Config.env: ["K=V", ...]`. Plain strings. Cross-deploy refs are decoration-time f-strings using `db.name`. | +| `volumes={".": "/workspace"}` | Host path resolved relative to worktree root; passed as `":[:ro]"`. RO opt-in via container-path suffix `":ro"`. | +| `workdir="/workspace"` | Bollard `Config.working_dir`. | +| `port_mapping={5432: hm.dev.port()}` | Bollard `HostConfig.port_bindings["5432/tcp"] = [{HostPort: ""}]` — daemon assigns ephemeral. | + +--- + +## §4 Lifecycle & signals + +### Boot + +1. Lock-free. Each session has unique container + network names. +2. Boot levels execute in topo order; failure of any one boot in a level fails the whole `up`. Partial teardown removes containers already started in this session plus the network. +3. Build-chain failures are reported as `[slug build] step X failed: ` and propagate. + +### Steady state + +- One log-mux task per session. +- One inspect-poller task at 2 s cadence detects unexpected container exits and logs them; does not tear down others. +- No automatic restarts. + +### Teardown + +- **First SIGINT/SIGTERM:** orderly teardown. + - For each container in reverse boot order: `docker stop` (10 s grace → SIGKILL), then `docker rm`. + - `docker network rm`. + - Exit 130 (SIGINT) or 143 (SIGTERM). +- **Second SIGINT during teardown:** hard exit (`process::exit(130)`); leftover containers + network are orphaned. User runs `hm dev down` to recover. +- **Process crashes (panic):** rust panic handler flushes a teardown call on best-effort; same recovery via `hm dev down`. + +### Orphan recovery + +`hm dev down` (no args) lists all containers labelled `harmont.worktree=` and removes them plus any associated networks. Idempotent. + +--- + +## §5 Error handling + +All errors follow PRINCIPLES § 5: point precisely, state observed, state fix. + +### Decoration-time (raised by `harmont-py`) + +``` +ValueError: hm.deploy slug must match ^[a-z][a-z0-9-]{0,30}$, got "API Service" + → rename the slug to a docker-safe form, e.g. "api-service" + +ValueError: hm.dev.port() can only appear as a port_mapping value, not as an env value. + → use a fixed value here, or query the resolved port via + `hm dev port-of ` after `hm dev up`. + +ValueError: hm.dev.deploy requires exactly one of `image=` or `from_=`, both were set. + → pick one. Use `image=` for a published image, `from_=` to build from a Step chain. + +RuntimeError: hm.deploy dep cycle: api -> db -> web -> api + → remove the cycle, or factor shared state into a target. + +ValueError: parameter `db` on @hm.deploy("api") has no type annotation. Every +parameter must carry a marker. + → add `db: hm.Dep[hm.Deployment]` (or `hm.Target[T]`) to inject it, + or give the parameter a default value. +``` + +### Runtime (raised by `hm dev`) + +``` +hm: docker daemon unreachable (Cannot connect to /var/run/docker.sock). + → start Docker Desktop, or run `sudo systemctl start docker`. +exit 5 + +hm: pull `postgres:16` failed: manifest unknown + → check the tag; `docker pull postgres:16` reproduces the failure. +exit 5 + +hm: build for `api` failed at step `cabal build all` (exit 1) + → see [api build] log lines above; run `hm run ` to debug. +exit 1 + +hm: slug `redis` not registered in this worktree's .harmont/ + → run `hm dev ls` to see registered slugs. +exit 5 + +hm: slug `db` matches multiple live sessions in this worktree: + 7a2f91 started 12:00:14 localhost:42173 + c4d8e0 started 12:05:31 localhost:42891 +pass `--session ` or run `hm dev ls`. +exit 5 + +hm: slug `db` registered but not running in this worktree. + → run `hm dev up db` first. +exit 4 +``` + +--- + +## §6 Testing + +### `harmont-py` unit tests (pytest) + +`tests/dev/` (new): + +- `test_decorator.py` — slug regex, duplicate-slug rejection, dep cycle detection, parameter-marker enforcement, fixture-injection produces a `Deployment` with `.name`. +- `test_port_sentinel.py` — `port()` outside `port_mapping` raises; sentinel equality; `repr`. +- `test_deploy_factory.py` — XOR of `image=` vs `from_=`; port_mapping value-type validation; env value-type validation; cmd coercion to tuple; volume path validation. +- `test_registry_dump.py` — golden JSON for the canonical db+api+web example; non-local entries marked `_unhandled`; deps list reflects fixture graph. +- `test_dump_cli.py` — `python -m harmont.dev --dump-registry` against a temp `.harmont/` writes the expected JSON to stdout. + +### `harmont-cli` unit tests (cargo test) + +`crates/hm/src/commands/dev/`: + +- `registry::tests` — serde round-trip of the v0 schema; unknown drivers parse as `Unhandled`. +- `topo::tests` — boot levels for db→api→web; `--no-deps` prunes correctly; cycle detection (defensive — should already be caught python-side). +- `logmux::tests` — partial-line buffering; ANSI prefix shape; `NO_COLOR` env strips colors. +- `port_of::tests` — single session returns plain int; multiple sessions return ambiguity error; missing slug exits 5; stopped slug exits 4. +- `naming::tests` — worktree-hash stable across invocations; session-id format `[0-9a-f]{6}`. + +### `harmont-cli` integration tests (cargo test, feature-gated) + +`crates/hm/tests/dev_integration.rs`, gated `--features docker-integration` and skipped when `DOCKER_HOST` unreachable: + +- Boot a single `postgres:16` deployment; assert `port-of` returns the inspected port; assert `psql -h localhost -p -U postgres -c 'select 1'` succeeds; teardown removes container + network. +- Boot db+api on bridge net; assert api container can `getent hosts db` and connect via `db:5432`. + +### Cross-repo "vibe" check (manual, documented in RELEASING.md) + +```bash +# In a temp dir +mkdir -p .harmont && cat > .harmont/pipelines.py <<'EOF' +import harmont as hm +@hm.deploy("db") +def db(): + return hm.dev.deploy(image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}) +EOF +hm dev up db & +sleep 5 +PGPASSWORD=dev psql -h localhost -p $(hm dev port-of db 5432) -U postgres -c 'select 1' +kill %1; wait +hm dev ls # should show nothing running +``` + +--- + +## Cross-repo file map + +### `harmont-py` + +``` +harmont/ + __init__.py # re-export hm.deploy, hm.Dep, hm.Deployment, hm.dev + _deploy.py # NEW: Deployment dataclass, top-level decorator, + # Dep[T] marker, dep-graph builder + _registry.py # MODIFY: add DEPLOYMENTS dict alongside REGISTRATIONS + dev/ + __init__.py # NEW: re-export hm.dev.deploy, hm.dev.port, + # hm.dev.LocalDeployment, hm.dev.dump_registry_json + __main__.py # NEW: `python -m harmont.dev --dump-registry` entry + _deployment.py # NEW: LocalDeployment dataclass + validation + _port.py # NEW: _PortSentinel + hm.dev.port() + _factory.py # NEW: hm.dev.deploy(...) factory + _registry_dump.py # NEW: dump_registry_json + JSON serializer + +tests/ + dev/ + __init__.py # NEW + test_decorator.py # NEW + test_port_sentinel.py # NEW + test_deploy_factory.py # NEW + test_registry_dump.py # NEW + test_dump_cli.py # NEW +``` + +### `harmont-cli` + +``` +crates/hm/src/ + cli.rs # MODIFY: add Dev(DevCommand) variant + subcommands + commands/ + mod.rs # MODIFY: register dev module + dev/ + mod.rs # NEW: subcommand dispatcher + registry.rs # NEW: invoke `python -m harmont.dev` + serde types + naming.rs # NEW: worktree-hash, session-id, container/network names + topo.rs # NEW: dep-graph topo sort + level grouping + network.rs # NEW: create/remove bridge network via bollard + logmux.rs # NEW: multi-source line-prefixed colored log stream + service_spec.rs # NEW: ServiceSpec + build_spec(reg, ctx, session, net) + up.rs # NEW: orchestrate boot + signal + teardown + down.rs # NEW: orphan sweep + ls.rs # NEW: registry walk + docker inspect merge + logs.rs # NEW: docker logs --follow shim + port_of.rs # NEW: inspect → host port lookup + ambiguity error + exec.rs # NEW: docker exec shim w/ TTY + orchestrator/ + docker_client.rs # MODIFY: add create_network, remove_network, + # attach_to_network, port_inspect, + # start_service, commit_container + mod.rs # MODIFY: pub fn build_image_from_pipeline + +crates/hm/tests/ + dev_integration.rs # NEW: feature-gated docker integration tests +``` + +--- + +## Open items deferred to follow-up specs + +- AWS / Fly / k8s drivers — when added, formalize a wire-format JSON IR for deployments and lift driver dispatch out of `hm dev` into a shared layer. +- Pipeline ↔ deployment auto-wiring (test pipelines that require deployments). +- Healthcheck DSL (`hm.dev.healthcheck(cmd=..., interval=...)`). +- Persistent named volumes. +- Daemon-mode `hm dev up --detach`. +- `hm dev up --watch` for hot-reload on `.harmont/*.py` changes. From 94fce1576148c6fc2009ce968dbeb78f7dbcfe6a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:15:47 +0000 Subject: [PATCH 02/28] docs: implementation plan for hm.deploy + hm.dev DSL 12 tasks, TDD-shaped: scaffold abstract Deployment, port sentinel, LocalDeployment, deploy() factory, Dep[T] marker + call_with_deps extension, @hm.deploy decorator, topo sort, dump_registry_json, python -m harmont.dev CLI shim, CLAUDE.md update, end-to-end canonical example, PR-readiness pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-21-hm-dev-deploy-py.md | 2400 +++++++++++++++++ 1 file changed, 2400 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md diff --git a/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md b/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md new file mode 100644 index 0000000..eb7f644 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md @@ -0,0 +1,2400 @@ +# `harmont-py`: hm.deploy + hm.dev DSL — 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:** Ship the Python DSL surface for local deployments: `@hm.deploy` (driver-agnostic decorator), `hm.Dep[T]` (PEP-593 fixture marker), `hm.Deployment` (abstract dataclass), `hm.dev.deploy(...)` (local-driver factory), `hm.dev.port()` (sentinel), and `harmont.dev.dump_registry_json()` + `python -m harmont.dev --dump-registry` CLI shim that emits the v0 JSON the Rust CLI consumes. + +**Architecture:** Top-level `harmont._deploy` houses the abstract `Deployment`, the `@hm.deploy` decorator, the `Dep[T]` marker, and the `DEPLOYMENTS` registry. Driver-specific code lives in `harmont/dev/`: `_deployment.py` (LocalDeployment), `_port.py` (sentinel), `_factory.py` (deploy(...)), `_registry_dump.py` (JSON emitter), and `__main__.py` (CLI shim). The dep-graph resolver extends the existing `harmont._deps.call_with_deps` so `Dep[T]` markers participate in the same fixture-injection pipeline as `Target[T]`. + +**Tech Stack:** Python 3.11+, frozen `dataclasses`, `typing.Annotated` (PEP 593), pytest (incl. `pytest.raises`). No new runtime deps. The cli side is out of scope for this plan. + +**Spec:** `docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md` (committed to this branch). Read § 1 (DSL surface) and § 5 (error handling) before starting — error-message shapes are tested literally. + +**Branch:** `feat/hm-dev-deploy`. Already created off `main`. + +**Commit cadence:** Every task ends with a commit. No exceptions. The commit subject line is in the example commands. + +--- + +## File Map + +### Create (harmont-py) + +- `harmont/_deploy.py` — abstract `Deployment` dataclass; `@hm.deploy` decorator; `Dep[T]` PEP-593 marker; `DEPLOYMENTS` registry; topo-sort + dep-graph resolver. +- `harmont/dev/__init__.py` — re-exports `deploy`, `port`, `LocalDeployment`, `dump_registry_json`. +- `harmont/dev/__main__.py` — `python -m harmont.dev --dump-registry` CLI shim. +- `harmont/dev/_deployment.py` — `LocalDeployment` frozen dataclass + `__post_init__` validation. +- `harmont/dev/_port.py` — `_PortSentinel` singleton + `port()` factory. +- `harmont/dev/_factory.py` — `deploy(...)` factory function (field validation + LocalDeployment construction). +- `harmont/dev/_registry_dump.py` — `dump_registry_json()` walks `DEPLOYMENTS` in topo order, emits the spec's JSON shape. +- `tests/dev/__init__.py` — empty, marks dir as test package. +- `tests/dev/conftest.py` — pytest fixture that clears `DEPLOYMENTS`, `_TARGETS_BY_NAME`, `REGISTRATIONS` between tests. +- `tests/dev/test_port_sentinel.py` — sentinel behavior + misuse. +- `tests/dev/test_local_deployment.py` — `LocalDeployment` field validation. +- `tests/dev/test_deploy_factory.py` — `hm.dev.deploy(...)` XOR rule, port_mapping shape, env/cmd coercion, volumes. +- `tests/dev/test_decorator.py` — slug regex, duplicate-slug, missing marker, dep cycle, `Dep[T]` injection, `Target[T]` injection co-exists. +- `tests/dev/test_registry_dump.py` — golden JSON for canonical db+api+web example; topo ordering; non-local entries marked `_unhandled`. +- `tests/dev/test_dump_cli.py` — `python -m harmont.dev --dump-registry` against a temp `.harmont/`. + +### Modify (harmont-py) + +- `harmont/__init__.py` — re-export `deploy` (the decorator), `Dep`, `Deployment`, and the `dev` submodule. +- `harmont/_deps.py` — extend `call_with_deps` + `validate_target_signature` + `_marker_for` to recognize `Dep[T]` markers and resolve them against `DEPLOYMENTS`. +- `harmont/_typing.py` — add `_DepMarker` sentinel + `Dep` PEP-593 alias. +- `CLAUDE.md` — append a "Deployments (`hm.deploy` + `hm.dev`)" section to the public surface table. + +### Do NOT touch + +- `harmont/_step.py`, `harmont/pipeline.py`, `harmont/keygen.py` — already do exactly what we need (`LocalDeployment.from_step` reuses `Step` as-is; `hm.dev.deploy(from_=Step)` lowers via `pipeline()` + `pipeline_to_json` at registry-dump time). +- Any toolchain (`harmont/haskell.py`, etc.) — unrelated. +- `harmont/_envelope.py` — that's the pipeline envelope; deployments get their own dumper. Look at it as a structural reference but do not modify. + +--- + +## Task 1: Scaffold `harmont/_deploy.py` with abstract `Deployment` + +Sets the cross-driver foundation. Empty subclass scaffolding for `LocalDeployment` (which gets fleshed out in Task 3). + +**Files:** +- Create: `harmont/_deploy.py` +- Test: `tests/dev/test_local_deployment.py` (only the abstract-type test in this task) +- Modify: `tests/dev/__init__.py` (create empty) +- Modify: `tests/dev/conftest.py` (create with reset fixture) + +- [ ] **Step 1: Create `tests/dev/__init__.py`** + +```python +``` + +Yes, empty. The file's existence marks the dir. + +- [ ] **Step 2: Create `tests/dev/conftest.py`** + +```python +"""Per-test reset of every registry the deploy DSL touches.""" +from __future__ import annotations + +import pytest + +from harmont._deploy import DEPLOYMENTS +from harmont._deps import _TARGETS_BY_NAME, _RESOLVING +from harmont._registry import REGISTRATIONS + + +@pytest.fixture(autouse=True) +def _reset_registries(): + """Clear every module-level registry before each test so order is irrelevant.""" + DEPLOYMENTS.clear() + _TARGETS_BY_NAME.clear() + _RESOLVING.clear() + REGISTRATIONS.clear() + yield + DEPLOYMENTS.clear() + _TARGETS_BY_NAME.clear() + _RESOLVING.clear() + REGISTRATIONS.clear() +``` + +- [ ] **Step 3: Write the failing test** + +In `tests/dev/test_local_deployment.py`: + +```python +"""Abstract Deployment + LocalDeployment construction tests.""" +from __future__ import annotations + +import pytest + +from harmont._deploy import Deployment + + +def test_deployment_is_abstract_dataclass(): + """Deployment carries name + driver, is frozen, and is constructible (sentinel-level).""" + d = Deployment(name="db", driver="local") + assert d.name == "db" + assert d.driver == "local" + with pytest.raises(Exception): + d.name = "other" # type: ignore[misc] # frozen +``` + +- [ ] **Step 4: Run test to verify it fails** + +```bash +pytest tests/dev/test_local_deployment.py -v +``` + +Expected: `ImportError: cannot import name 'Deployment' from 'harmont._deploy'`. + +- [ ] **Step 5: Implement `harmont/_deploy.py` (abstract type only)** + +```python +"""Driver-agnostic deployment registry, decorator, and Dep marker. + +This module is intentionally driver-free. Concrete deployment types +(``LocalDeployment``, future ``AwsDeployment``, …) live in their own +driver subpackages (``harmont.dev``, future ``harmont.aws``). +The registry stores deployments polymorphically; CLI subcommands filter +by ``isinstance`` or by the ``driver`` discriminator. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class Deployment: + """Abstract deployment record. Subclassed per driver. + + ``name`` is the slug the user passed to ``@hm.deploy``. + ``driver`` is the discriminator string ("local" for ``hm.dev``). + """ + name: str + driver: str + + +# Registry: slug -> zero-arg callable that re-invokes the user-defined +# function with deps resolved. Same shape as REGISTRATIONS for pipelines. +DEPLOYMENTS: dict[str, Callable[[], Deployment]] = {} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +pytest tests/dev/test_local_deployment.py -v +``` + +Expected: 1 passed. + +- [ ] **Step 7: Commit** + +```bash +git add harmont/_deploy.py tests/dev/__init__.py tests/dev/conftest.py tests/dev/test_local_deployment.py +git commit -m "$(cat <<'EOF' +feat(deploy): scaffold abstract Deployment dataclass + registry + +Sets the driver-agnostic foundation for hm.deploy. Concrete +LocalDeployment (Task 3) subclasses Deployment; the DEPLOYMENTS +registry stores polymorphic entries. Test-only reset fixture covers +DEPLOYMENTS plus the existing TARGETS/REGISTRATIONS registries so +all three are wiped between tests. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: `hm.dev.port()` sentinel + +**Files:** +- Create: `harmont/dev/__init__.py` +- Create: `harmont/dev/_port.py` +- Test: `tests/dev/test_port_sentinel.py` + +- [ ] **Step 1: Write the failing test** + +In `tests/dev/test_port_sentinel.py`: + +```python +"""hm.dev.port() sentinel: equality, repr, and structural use.""" +from __future__ import annotations + +from harmont.dev import port + + +def test_port_returns_sentinel_singleton(): + a = port() + b = port() + assert a is b # singleton — equality-by-identity is fine + assert a == b + + +def test_port_repr_is_stable_and_introspectable(): + assert repr(port()) == "" + + +def test_port_is_hashable(): + # frozen LocalDeployment uses port_mapping values inside a Mapping; + # being hashable means user code can put it in sets / tuple keys + # without surprise. + {port(): 1} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/dev/test_port_sentinel.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'harmont.dev'`. + +- [ ] **Step 3: Implement `harmont/dev/_port.py`** + +```python +"""hm.dev.port() — the OS-assigned-host-port sentinel. + +The sentinel is only meaningful as a value in +``hm.dev.deploy(..., port_mapping={CONTAINER_PORT: hm.dev.port()})``. +Any other position (env value, cmd arg, …) is rejected at the call +site that consumes it, with a fix-directed message per PRINCIPLES § 5. +""" +from __future__ import annotations + + +class _PortSentinel: + __slots__ = () + + def __repr__(self) -> str: + return "" + + def __eq__(self, other: object) -> bool: + return isinstance(other, _PortSentinel) + + def __hash__(self) -> int: + return hash(_PortSentinel) + + +_SINGLETON = _PortSentinel() + + +def port() -> _PortSentinel: + """Return the sentinel for an OS-assigned host port. + + Use only as a ``port_mapping`` value: + + hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + ) + """ + return _SINGLETON +``` + +- [ ] **Step 4: Implement `harmont/dev/__init__.py` (minimal, re-export only what's built)** + +```python +"""harmont.dev — local Docker deployment driver. + +Public surface (grows across tasks): + + deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) -> LocalDeployment + port() -> _PortSentinel + LocalDeployment (concrete subclass) + dump_registry_json() -> str +""" +from __future__ import annotations + +from ._port import _PortSentinel, port + +__all__ = ["_PortSentinel", "port"] +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +pytest tests/dev/test_port_sentinel.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git add harmont/dev/__init__.py harmont/dev/_port.py tests/dev/test_port_sentinel.py +git commit -m "$(cat <<'EOF' +feat(dev): add hm.dev.port() sentinel for OS-assigned host ports + +Singleton with stable repr and hash. Misuse outside port_mapping +is detected by deploy()'s field validation (Task 4), not at the +port() call site, so the error points at the exact misuse location. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: `LocalDeployment` frozen dataclass + +**Files:** +- Create: `harmont/dev/_deployment.py` +- Modify: `harmont/dev/__init__.py` +- Test: `tests/dev/test_local_deployment.py` (append) + +- [ ] **Step 1: Write the failing tests (append to `tests/dev/test_local_deployment.py`)** + +```python +from collections.abc import Mapping + +from harmont._deploy import Deployment +from harmont._step import Step, scratch +from harmont.dev import port +from harmont.dev._deployment import LocalDeployment +from harmont.dev._port import _PortSentinel + + +def test_local_deployment_is_a_deployment_with_driver_local(): + d = LocalDeployment( + name="db", + driver="local", + image="postgres:16", + from_step=None, + cmd=None, + port_mapping={5432: port()}, + env={}, + volumes={}, + workdir=None, + ) + assert isinstance(d, Deployment) + assert d.driver == "local" + assert d.image == "postgres:16" + + +def test_local_deployment_rejects_non_local_driver(): + import pytest + with pytest.raises(ValueError, match="driver must be 'local'"): + LocalDeployment( + name="db", driver="aws", + image="postgres:16", from_step=None, cmd=None, + port_mapping={5432: port()}, + env={}, volumes={}, workdir=None, + ) + + +def test_local_deployment_holds_step_chain(): + s = scratch().sh("echo hi", image="alpine:3.20") + d = LocalDeployment( + name="api", driver="local", + image=None, from_step=s, cmd=None, + port_mapping={8000: port()}, + env={}, volumes={}, workdir=None, + ) + assert d.from_step is s + assert d.image is None + + +def test_port_mapping_is_a_mapping_of_int_to_port_sentinel(): + d = LocalDeployment( + name="db", driver="local", + image="postgres:16", from_step=None, cmd=None, + port_mapping={5432: port()}, + env={}, volumes={}, workdir=None, + ) + assert isinstance(d.port_mapping, Mapping) + [(cport, sentinel)] = d.port_mapping.items() + assert cport == 5432 + assert isinstance(sentinel, _PortSentinel) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_local_deployment.py -v +``` + +Expected: ImportError or ModuleNotFoundError on `harmont.dev._deployment`. + +- [ ] **Step 3: Implement `harmont/dev/_deployment.py`** + +```python +"""LocalDeployment — the concrete dataclass for the local Docker driver. + +Construction is mediated by ``harmont.dev._factory.deploy(...)``; the +factory does input validation and coerces fields. ``__post_init__`` is +the last-line invariant check (driver must be 'local'). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from harmont._deploy import Deployment + +if TYPE_CHECKING: + from collections.abc import Mapping + + from harmont._step import Step + + from ._port import _PortSentinel + + +@dataclass(frozen=True) +class LocalDeployment(Deployment): + """Local Docker deployment record. + + Exactly one of ``image`` or ``from_step`` is non-None — enforced by + ``deploy(...)``. ``port_mapping`` keys are container ports (1..65535); + values are ``_PortSentinel`` (the ``hm.dev.port()`` singleton). + ``volumes`` maps host paths (relative or absolute) to container + paths (with optional ``:ro`` suffix). + """ + image: str | None + from_step: "Step | None" + cmd: tuple[str, ...] | None + port_mapping: "Mapping[int, _PortSentinel]" + env: "Mapping[str, str]" + volumes: "Mapping[str, str]" + workdir: str | None + + def __post_init__(self) -> None: + if self.driver != "local": + msg = ( + f"LocalDeployment.driver must be 'local', got {self.driver!r}\n" + " → use the harmont.dev._factory.deploy() function " + "instead of constructing LocalDeployment directly" + ) + raise ValueError(msg) +``` + +- [ ] **Step 4: Re-export from `harmont/dev/__init__.py`** + +Update `harmont/dev/__init__.py` so its content becomes: + +```python +"""harmont.dev — local Docker deployment driver. + +Public surface (grows across tasks): + + deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) -> LocalDeployment + port() -> _PortSentinel + LocalDeployment (concrete subclass) + dump_registry_json() -> str +""" +from __future__ import annotations + +from ._deployment import LocalDeployment +from ._port import _PortSentinel, port + +__all__ = ["LocalDeployment", "_PortSentinel", "port"] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/dev/test_local_deployment.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 6: Commit** + +```bash +git add harmont/dev/_deployment.py harmont/dev/__init__.py tests/dev/test_local_deployment.py +git commit -m "$(cat <<'EOF' +feat(dev): add LocalDeployment frozen dataclass + +Concrete subclass of Deployment for the local Docker driver. +__post_init__ enforces driver=='local'; everything else is a +plain dataclass field. The deploy(...) factory in Task 4 is the +sanctioned constructor. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: `hm.dev.deploy(...)` factory + field validation + +**Files:** +- Create: `harmont/dev/_factory.py` +- Modify: `harmont/dev/__init__.py` +- Test: `tests/dev/test_deploy_factory.py` + +- [ ] **Step 1: Write the failing tests** + +In `tests/dev/test_deploy_factory.py`: + +```python +"""hm.dev.deploy(...) field validation + LocalDeployment construction.""" +from __future__ import annotations + +import pytest + +from harmont._step import Step, scratch +from harmont.dev import LocalDeployment, deploy, port + + +def test_deploy_with_raw_image_returns_local_deployment(): + d = deploy( + image="postgres:16", + port_mapping={5432: port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + assert isinstance(d, LocalDeployment) + assert d.image == "postgres:16" + assert d.from_step is None + # name is set later by the @hm.deploy decorator; factory leaves it "" + assert d.name == "" + + +def test_deploy_with_from_step(): + s = scratch().sh("echo build", image="alpine:3.20") + d = deploy(from_=s, port_mapping={8000: port()}) + assert d.image is None + assert d.from_step is s + + +def test_deploy_requires_exactly_one_of_image_or_from(): + with pytest.raises(ValueError, match="exactly one of `image=` or `from_=`"): + deploy(port_mapping={5432: port()}) + with pytest.raises(ValueError, match="exactly one of `image=` or `from_=`"): + deploy(image="x", from_=scratch().sh("echo"), port_mapping={5432: port()}) + + +def test_port_mapping_keys_must_be_valid_container_ports(): + with pytest.raises(ValueError, match="port_mapping key must be int in"): + deploy(image="x", port_mapping={0: port()}) + with pytest.raises(ValueError, match="port_mapping key must be int in"): + deploy(image="x", port_mapping={70000: port()}) + with pytest.raises(ValueError, match="port_mapping key must be int in"): + deploy(image="x", port_mapping={"5432": port()}) # type: ignore[dict-item] + + +def test_port_mapping_values_must_be_hm_dev_port(): + with pytest.raises(ValueError, match="port_mapping value must be hm.dev.port"): + deploy(image="x", port_mapping={5432: 31337}) # type: ignore[dict-item] + + +def test_env_values_must_be_strings(): + with pytest.raises(ValueError, match="env value for 'PORT' must be str"): + deploy(image="x", port_mapping={5432: port()}, env={"PORT": 31337}) # type: ignore[dict-item] + + +def test_cmd_coerces_to_tuple_of_strings(): + d = deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", "-c", "shared_buffers=128MB"]) + assert d.cmd == ("postgres", "-c", "shared_buffers=128MB") + + +def test_cmd_rejects_non_string_elements(): + with pytest.raises(ValueError, match="cmd elements must be str"): + deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", 5432]) # type: ignore[list-item] + + +def test_volumes_keys_resolve_relative_to_worktree_at_dump_time(): + # The factory keeps host paths verbatim; resolution happens in + # _registry_dump.py. Here we only check that the dict is preserved. + d = deploy(image="x", port_mapping={5432: port()}, volumes={".": "/workspace"}) + assert dict(d.volumes) == {".": "/workspace"} + + +def test_workdir_must_be_absolute(): + with pytest.raises(ValueError, match="workdir must be an absolute path"): + deploy(image="x", port_mapping={5432: port()}, workdir="workspace") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_deploy_factory.py -v +``` + +Expected: ImportError on `deploy` from `harmont.dev`. + +- [ ] **Step 3: Implement `harmont/dev/_factory.py`** + +```python +"""hm.dev.deploy(...) — the public factory for LocalDeployment. + +Validation is deliberately strict and fix-directed. The @hm.deploy +decorator only learns the slug at decoration time, so this factory +emits LocalDeployment with name="" — the decorator stamps the slug +in afterwards via dataclasses.replace. +""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any + +from harmont._step import Step + +from ._deployment import LocalDeployment +from ._port import _PortSentinel + +if TYPE_CHECKING: + pass + + +def deploy( + *, + image: str | None = None, + from_: "Step | None" = None, + cmd: "Iterable[str] | None" = None, + port_mapping: "Mapping[int, _PortSentinel] | None" = None, + env: "Mapping[str, str] | None" = None, + volumes: "Mapping[str, str] | None" = None, + workdir: str | None = None, +) -> LocalDeployment: + """Construct a LocalDeployment. + + Exactly one of ``image`` or ``from_`` is required. ``port_mapping`` + keys are container ports (1..65535); values must be the + ``hm.dev.port()`` sentinel in v1. See the design spec § 1 for the + full validation table. + """ + if (image is None) == (from_ is None): + msg = ( + "hm.dev.deploy requires exactly one of `image=` or `from_=`, " + f"got image={image!r}, from_={from_!r}\n" + " → pick one. Use `image=\"...\"` for a published image, " + "`from_=` to build from a Step chain." + ) + raise ValueError(msg) + if from_ is not None and not isinstance(from_, Step): + msg = ( + f"hm.dev.deploy from_= must be a hm.Step, got {type(from_).__name__}\n" + " → pass a Step chain (e.g. hm.sh(...) or a @hm.target() value)" + ) + raise ValueError(msg) + + pm = _validate_port_mapping(port_mapping) + env_resolved = _validate_env(env) + volumes_resolved = _validate_volumes(volumes) + cmd_resolved = _validate_cmd(cmd) + workdir_resolved = _validate_workdir(workdir) + + return LocalDeployment( + name="", # decorator stamps the slug in + driver="local", + image=image, + from_step=from_, + cmd=cmd_resolved, + port_mapping=pm, + env=env_resolved, + volumes=volumes_resolved, + workdir=workdir_resolved, + ) + + +def _validate_port_mapping( + pm: "Mapping[int, _PortSentinel] | None", +) -> Mapping[int, _PortSentinel]: + if pm is None: + return {} + result: dict[int, _PortSentinel] = {} + for k, v in pm.items(): + if not isinstance(k, int) or k < 1 or k > 65535: + msg = ( + f"hm.dev.deploy port_mapping key must be int in 1..65535, " + f"got {k!r}\n" + " → keys are container ports the service listens on" + ) + raise ValueError(msg) + if not isinstance(v, _PortSentinel): + msg = ( + f"hm.dev.deploy port_mapping value must be hm.dev.port(), " + f"got {type(v).__name__}\n" + " → use hm.dev.port() to ask the OS for a free host port" + ) + raise ValueError(msg) + result[k] = v + return result + + +def _validate_env(env: "Mapping[str, str] | None") -> Mapping[str, str]: + if env is None: + return {} + for k, v in env.items(): + if not isinstance(k, str): + msg = f"hm.dev.deploy env key must be str, got {type(k).__name__}" + raise ValueError(msg) + if not isinstance(v, str): + msg = ( + f"hm.dev.deploy env value for {k!r} must be str, " + f"got {type(v).__name__}\n" + " → call str(...) at the call site so the conversion is explicit" + ) + raise ValueError(msg) + return dict(env) + + +def _validate_volumes( + volumes: "Mapping[str, str] | None", +) -> Mapping[str, str]: + if volumes is None: + return {} + for hp, cp in volumes.items(): + if not isinstance(hp, str) or not hp: + msg = ( + f"hm.dev.deploy volumes host path must be a non-empty str, " + f"got {hp!r}" + ) + raise ValueError(msg) + if not isinstance(cp, str) or not cp.startswith("/"): + msg = ( + f"hm.dev.deploy volumes container path {cp!r} must start with " + "'/'; append ':ro' for read-only mounts (e.g. '/workspace:ro')" + ) + raise ValueError(msg) + return dict(volumes) + + +def _validate_cmd(cmd: "Iterable[str] | None") -> tuple[str, ...] | None: + if cmd is None: + return None + items = tuple(cmd) + for x in items: + if not isinstance(x, str): + msg = ( + f"hm.dev.deploy cmd elements must be str, got {type(x).__name__}\n" + " → call str(...) at the call site so the conversion is explicit" + ) + raise ValueError(msg) + return items + + +def _validate_workdir(workdir: str | None) -> str | None: + if workdir is None: + return None + if not workdir.startswith("/"): + msg = ( + f"hm.dev.deploy workdir must be an absolute path, got {workdir!r}\n" + " → workdir is interpreted inside the container; " + "use a path that starts with '/'" + ) + raise ValueError(msg) + return workdir +``` + +- [ ] **Step 4: Re-export `deploy` from `harmont/dev/__init__.py`** + +Replace `harmont/dev/__init__.py` content with: + +```python +"""harmont.dev — local Docker deployment driver. + +Public surface (grows across tasks): + + deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) -> LocalDeployment + port() -> _PortSentinel + LocalDeployment (concrete subclass) + dump_registry_json() -> str (Task 8) +""" +from __future__ import annotations + +from ._deployment import LocalDeployment +from ._factory import deploy +from ._port import _PortSentinel, port + +__all__ = ["LocalDeployment", "_PortSentinel", "deploy", "port"] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/dev/test_deploy_factory.py -v +``` + +Expected: 10 passed. + +- [ ] **Step 6: Commit** + +```bash +git add harmont/dev/_factory.py harmont/dev/__init__.py tests/dev/test_deploy_factory.py +git commit -m "$(cat <<'EOF' +feat(dev): hm.dev.deploy(...) factory with field validation + +Strict, fix-directed validation per PRINCIPLES § 5: every error +message points at the misuse and states the fix. The factory leaves +name="" so the @hm.deploy decorator can stamp the slug in via +dataclasses.replace after deciding the slug from its arg or fn name. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: `hm.Dep[T]` marker + extend `call_with_deps` + +The marker lives in `harmont._typing` alongside `Target`; the resolver lives in `harmont._deps` alongside the existing target/baseimage resolution. The dep registry is `harmont._deploy.DEPLOYMENTS` (already created in Task 1). + +**Files:** +- Modify: `harmont/_typing.py` +- Modify: `harmont/_deps.py` +- Test: `tests/dev/test_dep_marker.py` (new) + +- [ ] **Step 1: Write the failing tests** + +In `tests/dev/test_dep_marker.py`: + +```python +"""hm.Dep[T] marker is detected; call_with_deps resolves it from DEPLOYMENTS.""" +from __future__ import annotations + +import pytest + +from harmont import Dep +from harmont._deploy import DEPLOYMENTS, Deployment +from harmont._deps import call_with_deps + + +def test_dep_marker_alias_subscripts_to_annotated(): + # Dep is PEP-593 Annotated[T, _DEP_MARKER]; subscripting works at + # both static and runtime levels. + from typing import get_args, get_origin + + T = Dep[Deployment] + assert get_origin(T) is not None + args = get_args(T) + assert args[0] is Deployment + + +def test_call_with_deps_resolves_dep_param_from_DEPLOYMENTS(): + # Register a fake deployment under the name "db". + DEPLOYMENTS["db"] = lambda: Deployment(name="db", driver="local") + + def consumer(db: Dep[Deployment]) -> Deployment: + return db + + result = call_with_deps(consumer) + assert isinstance(result, Deployment) + assert result.name == "db" + + +def test_call_with_deps_raises_when_dep_unknown(): + def consumer(redis: Dep[Deployment]) -> Deployment: + return redis + + with pytest.raises(ValueError, match="hm.Dep parameter 'redis' refers to"): + call_with_deps(consumer) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_dep_marker.py -v +``` + +Expected: ImportError — `Dep` not in `harmont`. + +- [ ] **Step 3: Add `_DepMarker` + `Dep` alias in `harmont/_typing.py`** + +Append to `harmont/_typing.py`: + +```python +class _DepMarker: + """Sentinel for Annotated metadata. Marks a parameter as a + dependency on another @hm.deploy by parameter name. The injected + value is the resolved Deployment. + """ + + __slots__ = () + + def __repr__(self) -> str: + return "" + + +_DEP_MARKER = _DepMarker() + + +# hm.Dep[Deployment] (or a concrete subclass) -> Annotated[T, _DEP_MARKER]. +Dep = Annotated[T, _DEP_MARKER] +``` + +- [ ] **Step 4: Extend `harmont/_deps.py` to resolve `_DepMarker`** + +The current `_marker_for` returns `_TARGET_MARKER` or `_BaseImageMarker`. Extend it to also return `_DEP_MARKER`. The current `call_with_deps` dispatches on the marker type. Add a `_DEP_MARKER` branch that looks up `harmont._deploy.DEPLOYMENTS`. + +Locate the existing `_marker_for` function in `harmont/_deps.py` and update it to recognize the dep marker. Then extend the resolver loop. Concrete edits (full new file content of `harmont/_deps.py` once edits are applied): + +```python +"""Shared dependency resolution for @hm.target, @hm.pipeline, and @hm.deploy. + +Strict-marker model: +- ``Target[T]`` — resolve by parameter name from the global + target registry; raise if not found. +- ``BaseImage["X"]`` — inject a scratch-rooted ``Step(image=X)``. +- ``Dep[T]`` — resolve by parameter name from + ``harmont._deploy.DEPLOYMENTS``; raise if + not found. +- plain param with default — bind the default value. +- anything else — raise at decoration time via + :func:`validate_target_signature`. + +Cycle detection uses a module-level "currently resolving" stack keyed +by function name; the dump_registry_json render clears it at the +start of every render along with the target memoization cache. +""" + +from __future__ import annotations + +import inspect +import typing +from typing import TYPE_CHECKING, Any + +from ._step import Step +from ._typing import _DEP_MARKER, _TARGET_MARKER, _BaseImageMarker, _DepMarker, _TargetMarker + +if TYPE_CHECKING: + from collections.abc import Callable + + +_TARGETS_BY_NAME: dict[str, Callable[[], Any]] = {} +_RESOLVING: list[str] = [] + + +def register_named_target(name: str, fn: Callable[[], Any]) -> None: + """Register a named target. Raises on duplicate name.""" + if name in _TARGETS_BY_NAME: + msg = ( + f"hm: duplicate target name {name!r}\n" + " → each @hm.target must have a unique name; pass " + 'name="..." to disambiguate' + ) + raise ValueError(msg) + _TARGETS_BY_NAME[name] = fn + + +def clear_target_names() -> None: + """Reset the name registry and cycle-detection stack.""" + _TARGETS_BY_NAME.clear() + _RESOLVING.clear() + + +def _param_kind_error(param: inspect.Parameter) -> str | None: + """Return a fix-directed error message if `param` has a forbidden kind.""" + kind = param.kind + if kind == inspect.Parameter.VAR_POSITIONAL: + return ( + "hm: target functions cannot take *args\n" + " → declare each dependency as an explicit named parameter" + ) + if kind == inspect.Parameter.VAR_KEYWORD: + return ( + "hm: target functions cannot take **kwargs\n" + " → declare each dependency as an explicit named parameter" + ) + if kind == inspect.Parameter.POSITIONAL_ONLY: + return ( + f"hm: target functions cannot have positional-only " + f"parameters (got {param.name!r})\n" + " → remove the '/' marker; parameters must be name-resolvable" + ) + return None + + +def _marker_for(annotation: Any) -> object | None: + """Return the hm-specific marker present on an ``Annotated[T, ...]`` + annotation, else None. Markers: ``_TargetMarker``, ``_BaseImageMarker``, + ``_DepMarker``. + """ + if typing.get_origin(annotation) is None: + return None + metadata = typing.get_args(annotation)[1:] + for m in metadata: + if isinstance(m, (_TargetMarker, _BaseImageMarker, _DepMarker)): + return m + return None + + +def validate_target_signature(fn: Callable[..., Any]) -> None: + """Raise at decoration time if any param lacks a marker or default.""" + sig = inspect.signature(fn) + hints = typing.get_type_hints(fn, include_extras=True) + for name, param in sig.parameters.items(): + kind_err = _param_kind_error(param) + if kind_err is not None: + raise ValueError(kind_err) + if param.default is not inspect.Parameter.empty: + continue + ann = hints.get(name) + if ann is None or _marker_for(ann) is None: + msg = ( + f"hm: parameter {name!r} on {fn.__name__} must carry a marker " + "or have a default.\n" + " → add `hm.Target[T]`, `hm.Dep[T]`, or " + "`Annotated[Step, hm.BaseImage(\"...\")]`, or set a default value." + ) + raise ValueError(msg) + + +def call_with_deps(fn: Callable[..., Any]) -> Any: + """Resolve fn's parameters via markers and call it.""" + sig = inspect.signature(fn) + hints = typing.get_type_hints(fn, include_extras=True) + + fn_id = getattr(fn, "__name__", repr(fn)) + if fn_id in _RESOLVING: + chain = " -> ".join([*_RESOLVING, fn_id]) + msg = f"hm: dependency cycle detected: {chain}" + raise RuntimeError(msg) + _RESOLVING.append(fn_id) + try: + kwargs: dict[str, Any] = {} + for name, param in sig.parameters.items(): + ann = hints.get(name) + marker = _marker_for(ann) if ann is not None else None + if marker is _TARGET_MARKER: + if name not in _TARGETS_BY_NAME: + msg = ( + f"hm.Target parameter {name!r} refers to no registered " + f"@hm.target — register one with that name, or pass " + '`name="..."` to disambiguate.' + ) + raise ValueError(msg) + kwargs[name] = _TARGETS_BY_NAME[name]() + elif isinstance(marker, _BaseImageMarker): + kwargs[name] = Step(image=marker.image) + elif marker is _DEP_MARKER: + # Local import to avoid circular: _deploy imports nothing from us. + from ._deploy import DEPLOYMENTS + + if name not in DEPLOYMENTS: + msg = ( + f"hm.Dep parameter {name!r} refers to no registered " + f"@hm.deploy — register one with that slug, or pass " + '`name="..."` to disambiguate.' + ) + raise ValueError(msg) + kwargs[name] = DEPLOYMENTS[name]() + elif param.default is not inspect.Parameter.empty: + kwargs[name] = param.default + else: + msg = ( + f"hm: parameter {name!r} on {fn_id} has no resolution.\n" + " → add a marker or default value." + ) + raise ValueError(msg) + return fn(**kwargs) + finally: + _RESOLVING.pop() +``` + +NB: The body above is the **complete new content** of `harmont/_deps.py`. The diff from the prior version is: imports gain `_DEP_MARKER`/`_DepMarker`; `_marker_for` recognizes them; `call_with_deps` resolves them via `DEPLOYMENTS`. Replace the entire file with this content; do not edit-in-place line by line, since `call_with_deps` and `validate_target_signature` change in coupled ways. + +- [ ] **Step 5: Add `Dep` to top-level `harmont/__init__.py`** + +Read `harmont/__init__.py`, locate the line `from ._typing import BaseImage, Target`, and replace it with: + +```python +from ._typing import BaseImage, Dep, Target +``` + +Add `"Dep"` to the `__all__` list in alphabetical position (between `Pipeline` and `Step`). + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +pytest tests/dev/test_dep_marker.py -v +``` + +Expected: 3 passed. + +Also re-run all existing tests to make sure `call_with_deps` changes didn't regress: + +```bash +pytest -v +``` + +Expected: every prior pass still passes; new tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add harmont/_typing.py harmont/_deps.py harmont/__init__.py tests/dev/test_dep_marker.py +git commit -m "$(cat <<'EOF' +feat(deploy): add hm.Dep[T] marker + extend call_with_deps resolver + +Dep[T] resolves a parameter against harmont._deploy.DEPLOYMENTS by +the parameter name (same shape as Target[T] vs _TARGETS_BY_NAME). +Cycle detection reuses the existing _RESOLVING stack so dep cycles +between deployments and dep cycles between targets share one detector. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: `@hm.deploy` decorator + +**Files:** +- Modify: `harmont/_deploy.py` (add the decorator + slug validator) +- Modify: `harmont/__init__.py` (re-export `deploy` — note name-clashes with `hm.dev.deploy`) +- Test: `tests/dev/test_decorator.py` (new) + +The clash: `hm.deploy` (decorator) vs `hm.dev.deploy` (factory). They live in different namespaces and are imported separately. The factory is `harmont.dev.deploy`; the decorator is `harmont.deploy`. The `harmont/__init__.py` re-exports `deploy = harmont._deploy.deploy` so `hm.deploy(...)` resolves to the decorator. `hm.dev.deploy(...)` is reached via the submodule. + +- [ ] **Step 1: Write the failing tests** + +In `tests/dev/test_decorator.py`: + +```python +"""@hm.deploy decorator: registration, slug derivation, fixture injection.""" +from __future__ import annotations + +import pytest + +import harmont as hm +from harmont._deploy import DEPLOYMENTS +from harmont.dev import LocalDeployment + + +def test_deploy_registers_under_explicit_slug(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + assert "db" in DEPLOYMENTS + resolved = DEPLOYMENTS["db"]() + assert isinstance(resolved, LocalDeployment) + assert resolved.name == "db" # decorator stamped slug in + assert resolved.image == "postgres:16" + + +def test_deploy_uses_function_name_when_slug_omitted(): + @hm.deploy() + def redis(): + return hm.dev.deploy(image="redis:7", port_mapping={6379: hm.dev.port()}) + + assert "redis" in DEPLOYMENTS + + +def test_deploy_rejects_invalid_slug(): + with pytest.raises(ValueError, match="invalid deployment slug"): + @hm.deploy("Bad Slug") + def x(): + return hm.dev.deploy(image="x", port_mapping={5432: hm.dev.port()}) + + +def test_deploy_rejects_duplicate_slug(): + @hm.deploy("db") + def db1(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + with pytest.raises(ValueError, match="duplicate deployment slug"): + @hm.deploy("db") + def db2(): + return hm.dev.deploy(image="postgres:15", port_mapping={5432: hm.dev.port()}) + + +def test_deploy_requires_marker_on_param(): + with pytest.raises(ValueError, match=r"parameter 'db' on .* must carry a marker"): + @hm.deploy("api") + def api(db): # type: ignore[no-untyped-def] + return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) + + +def test_deploy_injects_dep_value(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + # db.name comes from the resolved upstream Deployment + return hm.dev.deploy( + image="x", + port_mapping={8000: hm.dev.port()}, + env={"DB_HOST": db.name}, + ) + + resolved = DEPLOYMENTS["api"]() + assert resolved.env["DB_HOST"] == "db" + + +def test_deploy_with_explicit_name_arg(): + @hm.deploy("db", name="primary-db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + # The display name is held alongside the slug; the registry is keyed by slug. + assert "db" in DEPLOYMENTS + # In v1 we don't expose `name` separately on the returned Deployment; + # the slug IS the public identity. The kwarg is reserved for future use. + + +def test_deploy_function_can_return_remote_driver_value(): + # Simulate a future driver: a function that returns a Deployment with + # driver != "local". The decorator must register it without complaint. + from harmont._deploy import Deployment + + @hm.deploy("prod-api") + def prod_api(): + return Deployment(name="", driver="aws") + + resolved = DEPLOYMENTS["prod-api"]() + assert resolved.driver == "aws" + assert resolved.name == "prod-api" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_decorator.py -v +``` + +Expected: `AttributeError: module 'harmont' has no attribute 'deploy'`. + +- [ ] **Step 3: Implement `@hm.deploy` in `harmont/_deploy.py`** + +Replace the entire `harmont/_deploy.py` content with: + +```python +"""Driver-agnostic deployment registry, decorator, and Dep marker. + +This module is intentionally driver-free. Concrete deployment types +(``LocalDeployment``, future ``AwsDeployment``, …) live in their own +driver subpackages (``harmont.dev``, future ``harmont.aws``). +""" +from __future__ import annotations + +import dataclasses +import re +from dataclasses import dataclass +from functools import wraps +from typing import TYPE_CHECKING, Any + +from ._deps import call_with_deps, validate_target_signature + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class Deployment: + """Abstract deployment record. Subclassed per driver.""" + name: str + driver: str + + +DEPLOYMENTS: dict[str, "Callable[[], Deployment]"] = {} + + +_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$") + + +def _validate_slug(slug: str) -> None: + if not _SLUG_RE.match(slug): + msg = ( + f"hm: invalid deployment slug {slug!r}\n" + " → use lowercase letters, digits, and '-', " + "start with a letter, max 31 chars (Docker container name rules)" + ) + raise ValueError(msg) + + +def deploy( + slug: str | None = None, + *, + name: str | None = None, +) -> "Callable[[Callable[..., Any]], Callable[[], Deployment]]": + """Register a function as a deployment. + + The wrapped function returns a :class:`Deployment` (typically the + output of :func:`harmont.dev.deploy` or any future driver's factory). + Parameters are resolved via the markers used by ``@hm.target`` and + ``@hm.pipeline``, plus ``hm.Dep[T]`` for deployment-to-deployment + references. See ``docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md``. + """ + + def decorator(fn: "Callable[..., Any]") -> "Callable[[], Deployment]": + validate_target_signature(fn) + resolved_slug = slug if slug is not None else fn.__name__ + _validate_slug(resolved_slug) + if resolved_slug in DEPLOYMENTS: + msg = ( + f"hm: duplicate deployment slug {resolved_slug!r}\n" + " → each @hm.deploy must have a unique slug; pass an " + "explicit slug or `name=\"...\"` to disambiguate" + ) + raise ValueError(msg) + + @wraps(fn) + def wrapper() -> Deployment: + value = call_with_deps(fn) + if not isinstance(value, Deployment): + msg = ( + f"hm.deploy({resolved_slug!r}) must return a Deployment, " + f"got {type(value).__name__}\n" + " → return the output of hm.dev.deploy(...) or another " + "driver's factory" + ) + raise TypeError(msg) + # Stamp the slug into the returned dataclass. + return dataclasses.replace(value, name=resolved_slug) + + DEPLOYMENTS[resolved_slug] = wrapper + return wrapper + + return decorator +``` + +- [ ] **Step 4: Re-export `deploy`, `Dep`, `Deployment` from `harmont/__init__.py`** + +Read `harmont/__init__.py`. After the existing imports add: + +```python +from ._deploy import Deployment, deploy +``` + +And re-export `dev` as a submodule. Find the `from . import _decorator` line and add right after it: + +```python +from . import dev +``` + +Update the `__all__` list to include (in alphabetical position): `"Dep"`, `"Deployment"`, `"deploy"`, `"dev"`. + +The final `__all__` should look like (sorted): + +```python +__all__ = [ + "BaseImage", + "CacheCompose", + "CacheForever", + "CacheNone", + "CacheOnChange", + "CachePolicy", + "CacheTTL", + "Dep", + "Deployment", + "Pipeline", + "Step", + "Target", + "cmake", + "compose", + "composer", + "deploy", + "dev", + "dotnet", + "dump_registry_json", + "elm", + "forever", + "go", + "gradle", + "haskell", + "npm", + "ocaml", + "on_change", + "perl", + "pipeline", + "pipeline_to_json", + "pull_request", + "push", + "python", + "ruby", + "rust", + "schedule", + "scratch", + "sh", + "target", + "ttl", + "wait", + "zig", +] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/dev/test_decorator.py -v +``` + +Expected: 8 passed. + +Also run the full suite to confirm no regressions: + +```bash +pytest -v +``` + +Expected: every pre-existing test still passes; new tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add harmont/_deploy.py harmont/__init__.py tests/dev/test_decorator.py +git commit -m "$(cat <<'EOF' +feat(deploy): add @hm.deploy decorator with slug validation + Dep injection + +Decorator validates the slug regex (Docker container-name rules), +rejects duplicates, validates the function signature via the existing +validate_target_signature, and wraps the function so call_with_deps +resolves Target/Dep/BaseImage markers at registry-walk time. + +dataclasses.replace stamps the resolved slug into the returned +Deployment so the value seen by callers and the registry has +name= (the factory leaves name=""). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Topo sort + dep-graph extraction + +The registry dumper (Task 8) needs to walk deployments in dependency order. This task adds a pure dep-graph extractor + topo sort. No JSON yet. + +**Files:** +- Modify: `harmont/_deploy.py` (add `dep_graph` + `topo_order`) +- Test: `tests/dev/test_topo.py` (new) + +- [ ] **Step 1: Write the failing tests** + +In `tests/dev/test_topo.py`: + +```python +"""dep_graph extraction + topo_order on the deployment registry.""" +from __future__ import annotations + +import pytest + +import harmont as hm +from harmont._deploy import dep_graph, topo_order + + +def test_dep_graph_empty_when_no_deps(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + g = dep_graph() + assert g == {"db": ()} + + +def test_dep_graph_lists_param_names_in_order(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}, + env={"DB": db.name}) + + g = dep_graph() + assert g == {"db": (), "api": ("db",)} + + +def test_topo_order_is_stable_and_deps_first(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) + + @hm.deploy("web") + def web(api: hm.Dep[hm.Deployment]): + return hm.dev.deploy(image="x", port_mapping={3000: hm.dev.port()}) + + order = topo_order() + # db before api before web + assert order.index("db") < order.index("api") < order.index("web") + + +def test_topo_order_raises_on_cycle(): + # Build the cycle directly in the registry (bypasses normal decoration + # ordering, since at decoration time the upstream may not yet be + # registered — we only want to test the detector here). + from harmont._deploy import DEPLOYMENTS, Deployment + + @hm.deploy("a") + def a(b: hm.Dep[hm.Deployment]): + return Deployment(name="", driver="local") + + @hm.deploy("b") + def b(a: hm.Dep[hm.Deployment]): + return Deployment(name="", driver="local") + + with pytest.raises(RuntimeError, match="dep cycle"): + topo_order() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_topo.py -v +``` + +Expected: ImportError on `dep_graph` / `topo_order` from `harmont._deploy`. + +- [ ] **Step 3: Implement `dep_graph` and `topo_order` in `harmont/_deploy.py`** + +Append to `harmont/_deploy.py`: + +```python +def dep_graph() -> dict[str, tuple[str, ...]]: + """Return slug -> tuple of upstream slugs, in parameter order. + + Walks DEPLOYMENTS; for each registered slug, introspects the wrapped + function's signature for ``Dep[T]`` parameters. Plain defaults and + Target/BaseImage markers do not produce edges in the deploy graph. + """ + import inspect + import typing as _typing + + from ._typing import _DEP_MARKER + + out: dict[str, tuple[str, ...]] = {} + for slug, wrapper in DEPLOYMENTS.items(): + fn = wrapper.__wrapped__ # type: ignore[attr-defined] + sig = inspect.signature(fn) + hints = _typing.get_type_hints(fn, include_extras=True) + deps: list[str] = [] + for name in sig.parameters: + ann = hints.get(name) + if ann is None: + continue + if _typing.get_origin(ann) is None: + continue + metadata = _typing.get_args(ann)[1:] + if any(m is _DEP_MARKER for m in metadata): + deps.append(name) + out[slug] = tuple(deps) + return out + + +def topo_order() -> list[str]: + """Topological ordering of DEPLOYMENTS by dep_graph; deps first. + + Raises RuntimeError on cycles. Stable under insertion order for + independent slugs (preserves decoration order within a level). + """ + g = dep_graph() + # Kahn's algorithm w/ stable level ordering (insertion-order). + indeg: dict[str, int] = {slug: 0 for slug in g} + for upstreams in g.values(): + for u in upstreams: + if u in indeg: + indeg[u] # downstream depends on u; u has no incoming from here + # incoming edge is into the *dependent* slug, not the upstream. + # Rebuild: indeg of S = number of deps of S that exist in the registry. + for slug, upstreams in g.items(): + indeg[slug] = sum(1 for u in upstreams if u in g) + order: list[str] = [] + # Iterate in registry insertion order so the result is stable. + while True: + progressed = False + for slug in list(g.keys()): + if slug in order: + continue + if indeg[slug] == 0: + order.append(slug) + for downstream, upstreams in g.items(): + if slug in upstreams and downstream not in order: + indeg[downstream] -= 1 + progressed = True + if not progressed: + break + if len(order) != len(g): + unresolved = [s for s in g if s not in order] + msg = ( + f"hm: dep cycle among deployments: {', '.join(unresolved)}\n" + " → break the cycle, or factor shared state into a target" + ) + raise RuntimeError(msg) + return order +``` + +NB: The above intentionally keeps the implementation small and obvious (no graph library). The `__wrapped__` attribute is set by `functools.wraps` in the decorator, so the introspection finds the original function's signature. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/dev/test_topo.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add harmont/_deploy.py tests/dev/test_topo.py +git commit -m "$(cat <<'EOF' +feat(deploy): add dep_graph + topo_order over DEPLOYMENTS + +dep_graph walks the registry, introspects each wrapped function for +Dep[T] params, and emits slug -> tuple of upstream slugs in parameter +order. topo_order runs Kahn's algorithm with stable level ordering +(insertion order within a level) so the registry-dump output is +deterministic. Cycle detection raises RuntimeError listing the +unresolved slugs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: `dump_registry_json` for the local driver + +**Files:** +- Create: `harmont/dev/_registry_dump.py` +- Modify: `harmont/dev/__init__.py` +- Test: `tests/dev/test_registry_dump.py` + +- [ ] **Step 1: Write the failing tests** + +In `tests/dev/test_registry_dump.py`: + +```python +"""dump_registry_json — golden JSON shape for canonical examples.""" +from __future__ import annotations + +import json +from pathlib import Path + +import harmont as hm +from harmont._deploy import Deployment +from harmont.dev import dump_registry_json + + +def test_dump_minimal_local_deployment(): + @hm.deploy("db") + def db(): + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + assert out["schema_version"] == "0" + assert out["worktree"] == "/tmp/wt" + assert out["deployments"]["db"] == { + "driver": "local", + "image": "postgres:16", + "from": None, + "cmd": None, + "port_mapping": {"5432": "__hm_dev_port__"}, + "env": {"POSTGRES_PASSWORD": "dev"}, + "volumes": {}, + "workdir": None, + "deps": [], + } + + +def test_dump_with_cmd_workdir_volumes(): + @hm.deploy("db") + def db(): + return hm.dev.deploy( + image="postgres:16", + cmd=["postgres", "-c", "shared_buffers=128MB"], + port_mapping={5432: hm.dev.port()}, + volumes={".": "/workspace"}, + workdir="/workspace", + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + e = out["deployments"]["db"] + assert e["cmd"] == ["postgres", "-c", "shared_buffers=128MB"] + assert e["workdir"] == "/workspace" + assert e["volumes"] == {".": "/workspace"} + + +def test_dump_with_deps_emits_deps_array_in_param_order(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy( + image="x", port_mapping={8000: hm.dev.port()}, + env={"DB_HOST": db.name}, + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + assert out["deployments"]["api"]["deps"] == ["db"] + assert out["deployments"]["api"]["env"] == {"DB_HOST": "db"} + + +def test_dump_step_chain_emits_pipeline_v0_ir(): + @hm.deploy("api") + def api(): + return hm.dev.deploy( + from_=hm.sh("echo build", image="alpine:3.20"), + port_mapping={8000: hm.dev.port()}, + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + f = out["deployments"]["api"]["from"] + assert f["type"] == "step_chain" + assert f["pipeline_v0"]["version"] == "0" + assert f["pipeline_v0"]["steps"][0]["cmd"] == "echo build" + + +def test_dump_non_local_driver_is_marked_unhandled(): + @hm.deploy("prod-api") + def prod_api(): + # Future drivers will produce their own subclasses; for the v1 + # registry-dump test we use the abstract Deployment with a + # non-"local" driver to simulate the shape. + return Deployment(name="", driver="aws") + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + assert out["deployments"]["prod-api"] == {"driver": "aws", "_unhandled": True} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_registry_dump.py -v +``` + +Expected: ImportError on `dump_registry_json` from `harmont.dev`. + +- [ ] **Step 3: Implement `harmont/dev/_registry_dump.py`** + +```python +"""Local-driver registry dump. + +Walks ``harmont._deploy.DEPLOYMENTS`` in topo order, lowering each +``LocalDeployment`` to the JSON shape described in +``docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md`` § 1. +Non-local deployments are passed through as ``{"driver": X, +"_unhandled": true}`` so the CLI can render them in ``hm dev ls``. + +Step-chain deployments emit their pipeline as the existing v0 IR via +``harmont.pipeline()``; cache-keys are resolved through +``harmont.pipeline_to_json``'s standard path. +""" +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from harmont._deploy import DEPLOYMENTS, Deployment, dep_graph, topo_order +from harmont._target import clear_target_memo +from harmont.pipeline import pipeline as _assemble +from harmont.keygen import resolve_pipeline_keys + +from ._deployment import LocalDeployment +from ._port import _PortSentinel + +if TYPE_CHECKING: + from pathlib import Path + + +_SENTINEL_WIRE = "__hm_dev_port__" + + +def _lower_local(d: LocalDeployment, deps: tuple[str, ...]) -> dict[str, Any]: + return { + "driver": "local", + "image": d.image, + "from": _lower_from_step(d.from_step) if d.from_step is not None else None, + "cmd": list(d.cmd) if d.cmd is not None else None, + "port_mapping": { + str(cport): _SENTINEL_WIRE + for cport, value in d.port_mapping.items() + if isinstance(value, _PortSentinel) + }, + "env": dict(d.env), + "volumes": dict(d.volumes), + "workdir": d.workdir, + "deps": list(deps), + } + + +def _lower_from_step(step: Any) -> dict[str, Any]: + """Lower a single Step (the deployment's `from_=`) into the v0 IR shape. + + The Step is treated as the terminal leaf of a one-pipeline IR. + Cache-keys are resolved via the existing keygen so the Rust side + can use them as image tags without re-running the algorithm. + """ + ir = _assemble(step) + resolve_pipeline_keys( + ir.get("steps", []), + pipeline_org="hm-dev", + pipeline_slug="hm-dev-build", + now=0, + base_path=None, + env={}, + ) + return {"type": "step_chain", "pipeline_v0": ir} + + +def dump_registry_json( + *, + worktree_root: "Path | None" = None, +) -> str: + """Emit the v0 deployment-registry JSON. + + ``worktree_root`` is recorded so the CLI can resolve relative + ``volumes`` paths and the worktree-hash label. Pass the value + yourself in tests; production use comes through the CLI shim + (``python -m harmont.dev --dump-registry --worktree-root ``). + """ + from pathlib import Path as _Path + + clear_target_memo() + wt = _Path(worktree_root) if worktree_root is not None else _Path.cwd() + order = topo_order() + graph = dep_graph() + deployments: dict[str, dict[str, Any]] = {} + for slug in order: + value = DEPLOYMENTS[slug]() + if isinstance(value, LocalDeployment): + deployments[slug] = _lower_local(value, graph[slug]) + elif isinstance(value, Deployment): + deployments[slug] = {"driver": value.driver, "_unhandled": True} + else: + msg = ( + f"hm: @hm.deploy({slug!r}) returned {type(value).__name__}; " + "expected a Deployment subclass" + ) + raise TypeError(msg) + return json.dumps({ + "schema_version": "0", + "worktree": str(wt), + "deployments": deployments, + }) +``` + +- [ ] **Step 4: Re-export from `harmont/dev/__init__.py`** + +Replace `harmont/dev/__init__.py` with the final v1 content: + +```python +"""harmont.dev — local Docker deployment driver. + +Public surface: + + deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) -> LocalDeployment + port() -> _PortSentinel + LocalDeployment (concrete subclass) + dump_registry_json(*, worktree_root) -> str +""" +from __future__ import annotations + +from ._deployment import LocalDeployment +from ._factory import deploy +from ._port import _PortSentinel, port +from ._registry_dump import dump_registry_json + +__all__ = ["LocalDeployment", "_PortSentinel", "deploy", "dump_registry_json", "port"] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/dev/test_registry_dump.py -v +``` + +Expected: 5 passed. + +- [ ] **Step 6: Commit** + +```bash +git add harmont/dev/_registry_dump.py harmont/dev/__init__.py tests/dev/test_registry_dump.py +git commit -m "$(cat <<'EOF' +feat(dev): dump_registry_json emits the v0 deployment IR for the CLI + +Walks DEPLOYMENTS in topo order, lowering LocalDeployment values to +the schema documented in the spec (§ 1). Step-chain from_= values are +lowered via the existing harmont.pipeline() + keygen pipeline so the +Rust executor can run the chain and use the terminal key as the +build-image tag. Non-local drivers are passed through as +{"driver": X, "_unhandled": true} for hm dev ls. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: `python -m harmont.dev --dump-registry` CLI shim + +The Rust CLI spawns this subprocess to read the deployment registry. It walks `.harmont/*.py`, imports each (side-effect registration), then prints the registry JSON to stdout. + +**Files:** +- Create: `harmont/dev/__main__.py` +- Test: `tests/dev/test_dump_cli.py` + +- [ ] **Step 1: Write the failing test** + +In `tests/dev/test_dump_cli.py`: + +```python +"""`python -m harmont.dev --dump-registry` integration.""" +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap +from pathlib import Path + + +def test_dump_cli_walks_harmont_dir_and_prints_registry(tmp_path: Path): + pkg = tmp_path / ".harmont" + pkg.mkdir() + (pkg / "deploys.py").write_text(textwrap.dedent(""" + import harmont as hm + + @hm.deploy("db") + def db(): + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + """)) + result = subprocess.run( + [sys.executable, "-m", "harmont.dev", "--dump-registry"], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + out = json.loads(result.stdout) + assert out["schema_version"] == "0" + assert out["worktree"] == str(tmp_path) + assert "db" in out["deployments"] + assert out["deployments"]["db"]["image"] == "postgres:16" + + +def test_dump_cli_errors_when_no_harmont_dir(tmp_path: Path): + result = subprocess.run( + [sys.executable, "-m", "harmont.dev", "--dump-registry"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "no .harmont/ directory" in result.stderr + + +def test_dump_cli_errors_on_bad_argument(tmp_path: Path): + result = subprocess.run( + [sys.executable, "-m", "harmont.dev", "--no-such-flag"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert result.returncode == 2 # argparse default +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/dev/test_dump_cli.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'harmont.dev.__main__'`. + +- [ ] **Step 3: Implement `harmont/dev/__main__.py`** + +```python +"""`python -m harmont.dev` — registry-dump entry point for the CLI. + +Walks ``.harmont/*.py`` (importing each by file path), letting +``@hm.deploy``-decorated functions register themselves into +``harmont._deploy.DEPLOYMENTS`` as a side effect. Then emits the +deployment registry JSON to stdout. + +Errors go to stderr with exit code 1 (DSL error) or 2 (argparse +usage error), matching ``harmont``'s convention. +""" +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path + + +def _import_path(path: Path) -> None: + spec = importlib.util.spec_from_file_location( + name=f"_harmont_dev_user_{path.stem}", + location=str(path), + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load module from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + +def _walk_harmont_dir(root: Path) -> None: + harmont_dir = root / ".harmont" + if not harmont_dir.is_dir(): + print( + f"hm: no .harmont/ directory in {root}\n" + " → create .harmont/ and add @hm.deploy-decorated functions", + file=sys.stderr, + ) + sys.exit(1) + for py in sorted(harmont_dir.glob("*.py")): + _import_path(py) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m harmont.dev") + parser.add_argument( + "--dump-registry", + action="store_true", + help="walk .harmont/*.py and emit the v0 deployment registry JSON", + ) + parser.add_argument( + "--worktree-root", + type=Path, + default=None, + help="path to the worktree root; defaults to cwd", + ) + args = parser.parse_args(argv) + + if not args.dump_registry: + parser.error("nothing to do; pass --dump-registry") + return 2 + + from harmont.dev import dump_registry_json + + root = args.worktree_root if args.worktree_root is not None else Path.cwd() + _walk_harmont_dir(root) + print(dump_registry_json(worktree_root=root)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/dev/test_dump_cli.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add harmont/dev/__main__.py tests/dev/test_dump_cli.py +git commit -m "$(cat <<'EOF' +feat(dev): python -m harmont.dev --dump-registry CLI shim + +Walks .harmont/*.py, imports each by file path so @hm.deploy +registrations land in harmont._deploy.DEPLOYMENTS, then prints the +deployment registry JSON to stdout. The Rust CLI invokes this and +deserializes via serde (see harmont-cli plan). + +Missing .harmont/ exits 1 with a fix-directed stderr. Argparse handles +usage errors with exit 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Update `CLAUDE.md` public-surface documentation + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Append deployments section to `CLAUDE.md`** + +Read `CLAUDE.md`. Add the following section immediately before `## Cache keys` (the existing section): + +````markdown +## Deployments — `@hm.deploy` and `hm.dev` + +`@hm.deploy` is a driver-agnostic decorator that registers a function +as a long-lived service. The function returns a `Deployment` value +produced by a driver-specific factory; v1 ships only the local Docker +driver via `hm.dev.deploy(...)`. Future cloud drivers (`hm.aws.deploy`, +`hm.fly.deploy`) plug in without touching the top-level decorator. + +```python +import harmont as hm + +@hm.deploy("db") +def db() -> hm.Deployment: + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + +@hm.deploy("api") +def api( + db: hm.Dep[hm.Deployment], + api_image: hm.Target[hm.Step], +) -> hm.Deployment: + return hm.dev.deploy( + from_=api_image, + port_mapping={8000: hm.dev.port()}, + env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, + ) +``` + +Public surface: + +```python +hm.deploy(slug=None, *, name=None) # decorator +hm.Dep[T] # PEP-593 fixture marker +hm.Deployment # abstract dataclass + +hm.dev.deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) # -> LocalDeployment +hm.dev.port() # OS-assigned host port sentinel +hm.dev.LocalDeployment # concrete subclass +hm.dev.dump_registry_json(*, worktree_root) # -> v0 JSON +``` + +`hm.dev.port()` is only valid as a value in `port_mapping`. The host +port is assigned by Docker (via `-p :`) at `hm dev up` +time; query it from another terminal with `hm dev port-of +`. Ports are fresh on every `hm dev up`. + +The Rust CLI (`hm dev up`) shells out to `python -m harmont.dev +--dump-registry` to obtain the registry JSON. Schema is at +`docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md` § 1. +```` + +- [ ] **Step 2: Sanity-check the doc compiles in your head** + +Re-read CLAUDE.md top-to-bottom. Confirm no stale references and that the new section sits between the pipeline surface and the cache-key section. + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "$(cat <<'EOF' +docs: document hm.deploy + hm.dev in CLAUDE.md + +Adds the deployments section to the agent-facing doc with the +canonical example and the full public surface. Cross-links the +design spec for engineers who need the wire-format details. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: Full-suite green + canonical end-to-end sanity check + +A final integration test that mirrors the spec's canonical example. Confirms every piece works together. + +**Files:** +- Test: `tests/dev/test_canonical_example.py` (new) + +- [ ] **Step 1: Write the integration test** + +In `tests/dev/test_canonical_example.py`: + +```python +"""End-to-end test mirroring the spec's canonical db+api+web example.""" +from __future__ import annotations + +import json +from pathlib import Path + +import harmont as hm + + +def test_canonical_db_api_web_dumps_expected_shape(): + @hm.target() + def api_image() -> hm.Step: + return hm.sh("docker build -t myapi .", image="docker:24") + + @hm.deploy("db") + def db() -> hm.Deployment: + return hm.dev.deploy( + image="postgres:16", + cmd=["postgres", "-c", "shared_buffers=128MB"], + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + + @hm.deploy("api") + def api( + db: hm.Dep[hm.Deployment], + api_image: hm.Target[hm.Step], + ) -> hm.Deployment: + return hm.dev.deploy( + from_=api_image, + port_mapping={8000: hm.dev.port()}, + env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, + volumes={".": "/workspace"}, + workdir="/workspace", + ) + + @hm.deploy("web") + def web(api: hm.Dep[hm.Deployment]) -> hm.Deployment: + return hm.dev.deploy( + image="node:20", + port_mapping={3000: hm.dev.port()}, + env={"API_URL": f"http://{api.name}:8000"}, + ) + + raw = hm.dev.dump_registry_json(worktree_root=Path("/tmp/wt")) + out = json.loads(raw) + assert out["schema_version"] == "0" + assert list(out["deployments"].keys()) == ["db", "api", "web"] # topo order + assert out["deployments"]["api"]["deps"] == ["db"] + assert out["deployments"]["web"]["deps"] == ["api"] + assert out["deployments"]["api"]["env"]["DATABASE_URL"] == "postgres://db:5432/app" + assert out["deployments"]["web"]["env"]["API_URL"] == "http://api:8000" + # Step-chain `from_=` lowered through the existing v0 IR machinery + api_from = out["deployments"]["api"]["from"] + assert api_from["type"] == "step_chain" + assert api_from["pipeline_v0"]["version"] == "0" + assert any(s.get("cmd", "").startswith("docker build") + for s in api_from["pipeline_v0"]["steps"]) +``` + +- [ ] **Step 2: Run only this test** + +```bash +pytest tests/dev/test_canonical_example.py -v +``` + +Expected: 1 passed. + +- [ ] **Step 3: Run the full suite to confirm zero regressions** + +```bash +pytest -v +``` + +Expected: every test passes. If any pre-existing test fails, investigate `harmont/_deps.py` changes — they're the only cross-cutting modification. + +- [ ] **Step 4: Run lint + type-check (matches CLAUDE.md gate)** + +```bash +ruff check . +mypy harmont tests +``` + +Expected: both pass cleanly. The new code is fully type-annotated; if mypy complains, fix the annotations before committing — do not suppress. + +- [ ] **Step 5: Commit** + +```bash +git add tests/dev/test_canonical_example.py +git commit -m "$(cat <<'EOF' +test(dev): end-to-end canonical db+api+web example + +Mirrors the spec's worked example. Asserts topo order, dep edges, +cross-deploy f-string env values, and that from_=Step lowers through +the existing v0 IR pipeline. This is the "vibe check" gate before +the CLI plan can start consuming the JSON output. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: PR-readiness sanity pass + +**Files:** none modified. + +- [ ] **Step 1: Branch is up to date with main** + +```bash +git fetch origin main +git log --oneline origin/main..HEAD +``` + +Expected: a clean linear history of task commits. + +- [ ] **Step 2: Confirm public surface end-to-end** + +```bash +python -c " +import harmont as hm +print(hm.deploy, hm.Dep, hm.Deployment) +print(hm.dev.deploy, hm.dev.port, hm.dev.LocalDeployment, hm.dev.dump_registry_json) +" +``` + +Expected: every name prints without ImportError. If any fails, locate the missing re-export. + +- [ ] **Step 3: Run the dump CLI shim against the canonical example** + +```bash +mkdir /tmp/hm-deploy-smoke && cd /tmp/hm-deploy-smoke && mkdir .harmont && cat > .harmont/deploys.py <<'EOF' +import harmont as hm + +@hm.deploy("db") +def db(): + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) +EOF +python -m harmont.dev --dump-registry | python -m json.tool +``` + +Expected: a pretty-printed JSON document matching the spec's schema. + +- [ ] **Step 4: Commit ANY follow-up fixes (none if smoke is clean)** + +If you tweak anything in this pass, commit it with subject `chore: PR-readiness sanity pass`. Otherwise skip. + +- [ ] **Step 5: Done — branch ready for review** + +The `feat/hm-dev-deploy` branch on harmont-py is now feature-complete for v1. The harmont-cli plan (`/home/marko/harmont-cli/docs/superpowers/plans/2026-05-21-hm-dev-deploy-cli.md`) is the natural follow-up; the cli plan assumes harmont-py from this branch is installed (e.g., `pip install -e ../harmont-py` in the cli test environment). + +--- + +## Self-Review Notes (for the plan author, not the executor) + +Coverage of spec § 1 (DSL surface): +- `hm.deploy` decorator → Task 6. +- `hm.Dep[T]` marker → Task 5. +- `hm.Deployment` abstract type → Task 1. +- `hm.dev.deploy(...)` factory → Task 4. +- `hm.dev.port()` sentinel → Task 2. +- `LocalDeployment` dataclass → Task 3. +- `dump_registry_json` → Task 8. +- `python -m harmont.dev --dump-registry` shim → Task 9. +- Validation rules (slug regex, port_mapping shape, env value types, volumes container-path, workdir absolute, exactly-one-of image/from_) → Tasks 4, 6. +- Fixture-injection rules (param must have marker or default; cycles raise) → Tasks 5, 6, 7. + +Coverage of spec § 5 (error handling, decoration-time): +- "invalid deployment slug" → Task 6. +- "duplicate deployment slug" → Task 6. +- "hm.dev.deploy requires exactly one of image= or from_=" → Task 4. +- "port_mapping value must be hm.dev.port()" → Task 4. +- "parameter X must carry a marker" → Task 5 (via the extended `validate_target_signature`). +- "dep cycle" → Task 7. + +Not covered by this plan (correctly — they belong in the cli plan): +- Spec § 2 (CLI surface) — entirely cli-side. +- Spec § 3 (runtime / executor) — entirely cli-side. +- Spec § 4 (lifecycle & signals) — entirely cli-side. +- Spec § 5 runtime errors — entirely cli-side. +- Spec § 6 (cli unit + integration tests). + +Type / name consistency: +- `Deployment`, `LocalDeployment`, `deploy`, `Dep`, `port`, `dump_registry_json` are used identically across all tasks. +- `__hm_dev_port__` is the wire encoding everywhere it appears (Task 8 implementation + tests). +- `worktree_root` kwarg on `dump_registry_json` is used identically across Tasks 8, 9, 11. +- `from_step` (LocalDeployment field) vs `from_` (factory kwarg) — intentionally different (Python keyword conflict for the kwarg; the field is a normal attribute). From ccc621d592654bbd1f4c03c84bd6802c3ddc3cf1 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:28:14 +0000 Subject: [PATCH 03/28] feat(deploy): scaffold abstract Deployment dataclass + registry Sets the driver-agnostic foundation for hm.deploy. Concrete LocalDeployment (Task 3) subclasses Deployment; the DEPLOYMENTS registry stores polymorphic entries. Test-only reset fixture covers DEPLOYMENTS plus the existing TARGETS/REGISTRATIONS registries so all three are wiped between tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/_deploy.py | 31 ++++++++++++++++++++++++++++++ tests/dev/__init__.py | 0 tests/dev/conftest.py | 22 +++++++++++++++++++++ tests/dev/test_local_deployment.py | 15 +++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 harmont/_deploy.py create mode 100644 tests/dev/__init__.py create mode 100644 tests/dev/conftest.py create mode 100644 tests/dev/test_local_deployment.py diff --git a/harmont/_deploy.py b/harmont/_deploy.py new file mode 100644 index 0000000..7c8e9a4 --- /dev/null +++ b/harmont/_deploy.py @@ -0,0 +1,31 @@ +"""Driver-agnostic deployment registry, decorator, and Dep marker. + +This module is intentionally driver-free. Concrete deployment types +(``LocalDeployment``, future ``AwsDeployment``, …) live in their own +driver subpackages (``harmont.dev``, future ``harmont.aws``). +The registry stores deployments polymorphically; CLI subcommands filter +by ``isinstance`` or by the ``driver`` discriminator. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class Deployment: + """Abstract deployment record. Subclassed per driver. + + ``name`` is the slug the user passed to ``@hm.deploy``. + ``driver`` is the discriminator string ("local" for ``hm.dev``). + """ + name: str + driver: str + + +# Registry: slug -> zero-arg callable that re-invokes the user-defined +# function with deps resolved. Same shape as REGISTRATIONS for pipelines. +DEPLOYMENTS: dict[str, Callable[[], Deployment]] = {} diff --git a/tests/dev/__init__.py b/tests/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dev/conftest.py b/tests/dev/conftest.py new file mode 100644 index 0000000..ea476a5 --- /dev/null +++ b/tests/dev/conftest.py @@ -0,0 +1,22 @@ +"""Per-test reset of every registry the deploy DSL touches.""" +from __future__ import annotations + +import pytest + +from harmont._deploy import DEPLOYMENTS +from harmont._deps import _TARGETS_BY_NAME, _RESOLVING +from harmont._registry import REGISTRATIONS + + +@pytest.fixture(autouse=True) +def _reset_registries(): + """Clear every module-level registry before each test so order is irrelevant.""" + DEPLOYMENTS.clear() + _TARGETS_BY_NAME.clear() + _RESOLVING.clear() + REGISTRATIONS.clear() + yield + DEPLOYMENTS.clear() + _TARGETS_BY_NAME.clear() + _RESOLVING.clear() + REGISTRATIONS.clear() diff --git a/tests/dev/test_local_deployment.py b/tests/dev/test_local_deployment.py new file mode 100644 index 0000000..d10b09f --- /dev/null +++ b/tests/dev/test_local_deployment.py @@ -0,0 +1,15 @@ +"""Abstract Deployment + LocalDeployment construction tests.""" +from __future__ import annotations + +import pytest + +from harmont._deploy import Deployment + + +def test_deployment_is_abstract_dataclass(): + """Deployment carries name + driver, is frozen, and is constructible (sentinel-level).""" + d = Deployment(name="db", driver="local") + assert d.name == "db" + assert d.driver == "local" + with pytest.raises(Exception): + d.name = "other" # type: ignore[misc] # frozen From 838c4aa0aab0beb7345125f784f49fc6cf143fab Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:31:40 +0000 Subject: [PATCH 04/28] fix(test): conftest resets use canonical public helpers Reviewer caught that the raw _TARGETS_BY_NAME / _RESOLVING clears miss _TARGET_CACHE (the per-render target memo in harmont/_target.py), so future tests using @hm.target would leak memoized values across tests. Switch to clear_registry() + clear_target_cache(), matching the pattern in tests/test_registry.py and tests/test_target.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/dev/conftest.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/dev/conftest.py b/tests/dev/conftest.py index ea476a5..ba961a6 100644 --- a/tests/dev/conftest.py +++ b/tests/dev/conftest.py @@ -4,19 +4,17 @@ import pytest from harmont._deploy import DEPLOYMENTS -from harmont._deps import _TARGETS_BY_NAME, _RESOLVING -from harmont._registry import REGISTRATIONS +from harmont._registry import clear_registry +from harmont._target import clear_target_cache @pytest.fixture(autouse=True) def _reset_registries(): """Clear every module-level registry before each test so order is irrelevant.""" DEPLOYMENTS.clear() - _TARGETS_BY_NAME.clear() - _RESOLVING.clear() - REGISTRATIONS.clear() + clear_registry() + clear_target_cache() yield DEPLOYMENTS.clear() - _TARGETS_BY_NAME.clear() - _RESOLVING.clear() - REGISTRATIONS.clear() + clear_registry() + clear_target_cache() From 0e056437d7fd4e065cc85ba985bd9baeac510de1 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:33:37 +0000 Subject: [PATCH 05/28] feat(dev): add hm.dev.port() sentinel for OS-assigned host ports Singleton with stable repr and hash. Misuse outside port_mapping is detected by deploy()'s field validation (Task 4), not at the port() call site, so the error points at the exact misuse location. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__init__.py | 16 ++++++++++++++ harmont/dev/_port.py | 37 +++++++++++++++++++++++++++++++++ tests/dev/test_port_sentinel.py | 22 ++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 harmont/dev/__init__.py create mode 100644 harmont/dev/_port.py create mode 100644 tests/dev/test_port_sentinel.py diff --git a/harmont/dev/__init__.py b/harmont/dev/__init__.py new file mode 100644 index 0000000..9135601 --- /dev/null +++ b/harmont/dev/__init__.py @@ -0,0 +1,16 @@ +"""harmont.dev — local Docker deployment driver. + +Public surface (grows across tasks): + + deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) -> LocalDeployment + port() -> _PortSentinel + LocalDeployment (concrete subclass) + dump_registry_json() -> str +""" +from __future__ import annotations + +from ._port import _PortSentinel, port + +__all__ = ["_PortSentinel", "port"] diff --git a/harmont/dev/_port.py b/harmont/dev/_port.py new file mode 100644 index 0000000..5bef7d5 --- /dev/null +++ b/harmont/dev/_port.py @@ -0,0 +1,37 @@ +"""hm.dev.port() — the OS-assigned-host-port sentinel. + +The sentinel is only meaningful as a value in +``hm.dev.deploy(..., port_mapping={CONTAINER_PORT: hm.dev.port()})``. +Any other position (env value, cmd arg, …) is rejected at the call +site that consumes it, with a fix-directed message per PRINCIPLES § 5. +""" +from __future__ import annotations + + +class _PortSentinel: + __slots__ = () + + def __repr__(self) -> str: + return "" + + def __eq__(self, other: object) -> bool: + return isinstance(other, _PortSentinel) + + def __hash__(self) -> int: + return hash(_PortSentinel) + + +_SINGLETON = _PortSentinel() + + +def port() -> _PortSentinel: + """Return the sentinel for an OS-assigned host port. + + Use only as a ``port_mapping`` value: + + hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + ) + """ + return _SINGLETON diff --git a/tests/dev/test_port_sentinel.py b/tests/dev/test_port_sentinel.py new file mode 100644 index 0000000..97b3ca9 --- /dev/null +++ b/tests/dev/test_port_sentinel.py @@ -0,0 +1,22 @@ +"""hm.dev.port() sentinel: equality, repr, and structural use.""" +from __future__ import annotations + +from harmont.dev import port + + +def test_port_returns_sentinel_singleton(): + a = port() + b = port() + assert a is b # singleton — equality-by-identity is fine + assert a == b + + +def test_port_repr_is_stable_and_introspectable(): + assert repr(port()) == "" + + +def test_port_is_hashable(): + # frozen LocalDeployment uses port_mapping values inside a Mapping; + # being hashable means user code can put it in sets / tuple keys + # without surprise. + {port(): 1} From 7dd580c8689ac223a702bce509931c9cef52f5d8 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:36:34 +0000 Subject: [PATCH 06/28] fix(dev): drop _PortSentinel from __all__; explicit hashability assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught two minor issues: 1. `_PortSentinel` in __all__ violates the project's underscore-prefix private-name convention (cf. harmont/_typing.py — _TargetMarker stays private, Target is the public alias). Downstream isinstance checks import directly from harmont.dev._port. 2. The bare {port(): 1} expression would trip ruff B018 (useless expression) at Task 11's lint gate. Switched to an explicit assert {port(): 1}[port()] == 1 — still tests hashability AND key-equality, lint-clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__init__.py | 4 ++-- tests/dev/test_port_sentinel.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/harmont/dev/__init__.py b/harmont/dev/__init__.py index 9135601..03cc904 100644 --- a/harmont/dev/__init__.py +++ b/harmont/dev/__init__.py @@ -11,6 +11,6 @@ """ from __future__ import annotations -from ._port import _PortSentinel, port +from ._port import port -__all__ = ["_PortSentinel", "port"] +__all__ = ["port"] diff --git a/tests/dev/test_port_sentinel.py b/tests/dev/test_port_sentinel.py index 97b3ca9..6ef994f 100644 --- a/tests/dev/test_port_sentinel.py +++ b/tests/dev/test_port_sentinel.py @@ -19,4 +19,4 @@ def test_port_is_hashable(): # frozen LocalDeployment uses port_mapping values inside a Mapping; # being hashable means user code can put it in sets / tuple keys # without surprise. - {port(): 1} + assert {port(): 1}[port()] == 1 From 66d7385941cdd277c6bd4b25f9edcf966b7621e9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:38:17 +0000 Subject: [PATCH 07/28] feat(dev): add LocalDeployment frozen dataclass Concrete subclass of Deployment for the local Docker driver. __post_init__ enforces driver=='local'; everything else is a plain dataclass field. The deploy(...) factory in Task 4 is the sanctioned constructor. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__init__.py | 3 +- harmont/dev/_deployment.py | 47 ++++++++++++++++++++++ tests/dev/test_local_deployment.py | 63 ++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 harmont/dev/_deployment.py diff --git a/harmont/dev/__init__.py b/harmont/dev/__init__.py index 03cc904..9ff9389 100644 --- a/harmont/dev/__init__.py +++ b/harmont/dev/__init__.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +from ._deployment import LocalDeployment from ._port import port -__all__ = ["port"] +__all__ = ["LocalDeployment", "port"] diff --git a/harmont/dev/_deployment.py b/harmont/dev/_deployment.py new file mode 100644 index 0000000..19deaac --- /dev/null +++ b/harmont/dev/_deployment.py @@ -0,0 +1,47 @@ +"""LocalDeployment — the concrete dataclass for the local Docker driver. + +Construction is mediated by ``harmont.dev._factory.deploy(...)``; the +factory does input validation and coerces fields. ``__post_init__`` is +the last-line invariant check (driver must be 'local'). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from harmont._deploy import Deployment + +if TYPE_CHECKING: + from collections.abc import Mapping + + from harmont._step import Step + + from ._port import _PortSentinel + + +@dataclass(frozen=True) +class LocalDeployment(Deployment): + """Local Docker deployment record. + + Exactly one of ``image`` or ``from_step`` is non-None — enforced by + ``deploy(...)``. ``port_mapping`` keys are container ports (1..65535); + values are ``_PortSentinel`` (the ``hm.dev.port()`` singleton). + ``volumes`` maps host paths (relative or absolute) to container + paths (with optional ``:ro`` suffix). + """ + image: str | None + from_step: "Step | None" + cmd: tuple[str, ...] | None + port_mapping: "Mapping[int, _PortSentinel]" + env: "Mapping[str, str]" + volumes: "Mapping[str, str]" + workdir: str | None + + def __post_init__(self) -> None: + if self.driver != "local": + msg = ( + f"LocalDeployment.driver must be 'local', got {self.driver!r}\n" + " → use the harmont.dev._factory.deploy() function " + "instead of constructing LocalDeployment directly" + ) + raise ValueError(msg) diff --git a/tests/dev/test_local_deployment.py b/tests/dev/test_local_deployment.py index d10b09f..fb396bf 100644 --- a/tests/dev/test_local_deployment.py +++ b/tests/dev/test_local_deployment.py @@ -13,3 +13,66 @@ def test_deployment_is_abstract_dataclass(): assert d.driver == "local" with pytest.raises(Exception): d.name = "other" # type: ignore[misc] # frozen + + +# --------------------------------------------------------------------------- +# Task 3: LocalDeployment tests +# --------------------------------------------------------------------------- +from collections.abc import Mapping + +from harmont._step import Step, scratch +from harmont.dev import port +from harmont.dev._deployment import LocalDeployment +from harmont.dev._port import _PortSentinel + + +def test_local_deployment_is_a_deployment_with_driver_local(): + d = LocalDeployment( + name="db", + driver="local", + image="postgres:16", + from_step=None, + cmd=None, + port_mapping={5432: port()}, + env={}, + volumes={}, + workdir=None, + ) + assert isinstance(d, Deployment) + assert d.driver == "local" + assert d.image == "postgres:16" + + +def test_local_deployment_rejects_non_local_driver(): + with pytest.raises(ValueError, match="driver must be 'local'"): + LocalDeployment( + name="db", driver="aws", + image="postgres:16", from_step=None, cmd=None, + port_mapping={5432: port()}, + env={}, volumes={}, workdir=None, + ) + + +def test_local_deployment_holds_step_chain(): + s = scratch().sh("echo hi", image="alpine:3.20") + d = LocalDeployment( + name="api", driver="local", + image=None, from_step=s, cmd=None, + port_mapping={8000: port()}, + env={}, volumes={}, workdir=None, + ) + assert d.from_step is s + assert d.image is None + + +def test_port_mapping_is_a_mapping_of_int_to_port_sentinel(): + d = LocalDeployment( + name="db", driver="local", + image="postgres:16", from_step=None, cmd=None, + port_mapping={5432: port()}, + env={}, volumes={}, workdir=None, + ) + assert isinstance(d.port_mapping, Mapping) + [(cport, sentinel)] = d.port_mapping.items() + assert cport == 5432 + assert isinstance(sentinel, _PortSentinel) From 7aafa31ddac1e1fd90ccc1448a0a1664da180a8e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:41:10 +0000 Subject: [PATCH 08/28] fix(test): drop unused Step import in test_local_deployment Reviewer caught it as F401 dead-import (Step is imported alongside scratch but never referenced). Trimmed to just scratch. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/dev/test_local_deployment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dev/test_local_deployment.py b/tests/dev/test_local_deployment.py index fb396bf..4e965a8 100644 --- a/tests/dev/test_local_deployment.py +++ b/tests/dev/test_local_deployment.py @@ -20,7 +20,7 @@ def test_deployment_is_abstract_dataclass(): # --------------------------------------------------------------------------- from collections.abc import Mapping -from harmont._step import Step, scratch +from harmont._step import scratch from harmont.dev import port from harmont.dev._deployment import LocalDeployment from harmont.dev._port import _PortSentinel From 4ae33d68394303ad40dc4e091ad3dde4584a3b07 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:44:05 +0000 Subject: [PATCH 09/28] feat(dev): hm.dev.deploy(...) factory with field validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict, fix-directed validation per PRINCIPLES § 5: every error message points at the misuse and states the fix. The factory leaves name="" so the @hm.deploy decorator can stamp the slug in via dataclasses.replace after deciding the slug from its arg or fn name. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__init__.py | 5 +- harmont/dev/_factory.py | 156 +++++++++++++++++++++++++++++++ tests/dev/test_deploy_factory.py | 75 +++++++++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 harmont/dev/_factory.py create mode 100644 tests/dev/test_deploy_factory.py diff --git a/harmont/dev/__init__.py b/harmont/dev/__init__.py index 9ff9389..c9b8d15 100644 --- a/harmont/dev/__init__.py +++ b/harmont/dev/__init__.py @@ -7,11 +7,12 @@ volumes=None, workdir=None) -> LocalDeployment port() -> _PortSentinel LocalDeployment (concrete subclass) - dump_registry_json() -> str + dump_registry_json() -> str (Task 8) """ from __future__ import annotations from ._deployment import LocalDeployment +from ._factory import deploy from ._port import port -__all__ = ["LocalDeployment", "port"] +__all__ = ["LocalDeployment", "deploy", "port"] diff --git a/harmont/dev/_factory.py b/harmont/dev/_factory.py new file mode 100644 index 0000000..a10cac6 --- /dev/null +++ b/harmont/dev/_factory.py @@ -0,0 +1,156 @@ +"""hm.dev.deploy(...) — the public factory for LocalDeployment. + +Validation is deliberately strict and fix-directed. The @hm.deploy +decorator only learns the slug at decoration time, so this factory +emits LocalDeployment with name="" — the decorator stamps the slug +in afterwards via dataclasses.replace. +""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping + +from harmont._step import Step + +from ._deployment import LocalDeployment +from ._port import _PortSentinel + + +def deploy( + *, + image: str | None = None, + from_: Step | None = None, + cmd: Iterable[str] | None = None, + port_mapping: Mapping[int, _PortSentinel] | None = None, + env: Mapping[str, str] | None = None, + volumes: Mapping[str, str] | None = None, + workdir: str | None = None, +) -> LocalDeployment: + """Construct a LocalDeployment. + + Exactly one of ``image`` or ``from_`` is required. ``port_mapping`` + keys are container ports (1..65535); values must be the + ``hm.dev.port()`` sentinel in v1. See the design spec § 1 for the + full validation table. + """ + if (image is None) == (from_ is None): + msg = ( + "hm.dev.deploy requires exactly one of `image=` or `from_=`, " + f"got image={image!r}, from_={from_!r}\n" + " → pick one. Use `image=\"...\"` for a published image, " + "`from_=` to build from a Step chain." + ) + raise ValueError(msg) + if from_ is not None and not isinstance(from_, Step): + msg = ( + f"hm.dev.deploy from_= must be a hm.Step, got {type(from_).__name__}\n" + " → pass a Step chain (e.g. hm.sh(...) or a @hm.target() value)" + ) + raise ValueError(msg) + + pm = _validate_port_mapping(port_mapping) + env_resolved = _validate_env(env) + volumes_resolved = _validate_volumes(volumes) + cmd_resolved = _validate_cmd(cmd) + workdir_resolved = _validate_workdir(workdir) + + return LocalDeployment( + name="", # decorator stamps the slug in + driver="local", + image=image, + from_step=from_, + cmd=cmd_resolved, + port_mapping=pm, + env=env_resolved, + volumes=volumes_resolved, + workdir=workdir_resolved, + ) + + +def _validate_port_mapping( + pm: Mapping[int, _PortSentinel] | None, +) -> Mapping[int, _PortSentinel]: + if pm is None: + return {} + result: dict[int, _PortSentinel] = {} + for k, v in pm.items(): + if not isinstance(k, int) or k < 1 or k > 65535: + msg = ( + f"hm.dev.deploy port_mapping key must be int in 1..65535, " + f"got {k!r}\n" + " → keys are container ports the service listens on" + ) + raise ValueError(msg) + if not isinstance(v, _PortSentinel): + msg = ( + f"hm.dev.deploy port_mapping value must be hm.dev.port(), " + f"got {type(v).__name__}\n" + " → use hm.dev.port() to ask the OS for a free host port" + ) + raise ValueError(msg) + result[k] = v + return result + + +def _validate_env(env: Mapping[str, str] | None) -> Mapping[str, str]: + if env is None: + return {} + for k, v in env.items(): + if not isinstance(k, str): + msg = f"hm.dev.deploy env key must be str, got {type(k).__name__}" + raise ValueError(msg) + if not isinstance(v, str): + msg = ( + f"hm.dev.deploy env value for {k!r} must be str, " + f"got {type(v).__name__}\n" + " → call str(...) at the call site so the conversion is explicit" + ) + raise ValueError(msg) + return dict(env) + + +def _validate_volumes( + volumes: Mapping[str, str] | None, +) -> Mapping[str, str]: + if volumes is None: + return {} + for hp, cp in volumes.items(): + if not isinstance(hp, str) or not hp: + msg = ( + f"hm.dev.deploy volumes host path must be a non-empty str, " + f"got {hp!r}" + ) + raise ValueError(msg) + if not isinstance(cp, str) or not cp.startswith("/"): + msg = ( + f"hm.dev.deploy volumes container path {cp!r} must start with " + "'/'; append ':ro' for read-only mounts (e.g. '/workspace:ro')" + ) + raise ValueError(msg) + return dict(volumes) + + +def _validate_cmd(cmd: Iterable[str] | None) -> tuple[str, ...] | None: + if cmd is None: + return None + items = tuple(cmd) + for x in items: + if not isinstance(x, str): + msg = ( + f"hm.dev.deploy cmd elements must be str, got {type(x).__name__}\n" + " → call str(...) at the call site so the conversion is explicit" + ) + raise ValueError(msg) + return items + + +def _validate_workdir(workdir: str | None) -> str | None: + if workdir is None: + return None + if not workdir.startswith("/"): + msg = ( + f"hm.dev.deploy workdir must be an absolute path, got {workdir!r}\n" + " → workdir is interpreted inside the container; " + "use a path that starts with '/'" + ) + raise ValueError(msg) + return workdir diff --git a/tests/dev/test_deploy_factory.py b/tests/dev/test_deploy_factory.py new file mode 100644 index 0000000..48ef748 --- /dev/null +++ b/tests/dev/test_deploy_factory.py @@ -0,0 +1,75 @@ +"""hm.dev.deploy(...) field validation + LocalDeployment construction.""" +from __future__ import annotations + +import pytest + +from harmont._step import scratch +from harmont.dev import LocalDeployment, deploy, port + + +def test_deploy_with_raw_image_returns_local_deployment(): + d = deploy( + image="postgres:16", + port_mapping={5432: port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + assert isinstance(d, LocalDeployment) + assert d.image == "postgres:16" + assert d.from_step is None + # name is set later by the @hm.deploy decorator; factory leaves it "" + assert d.name == "" + + +def test_deploy_with_from_step(): + s = scratch().sh("echo build", image="alpine:3.20") + d = deploy(from_=s, port_mapping={8000: port()}) + assert d.image is None + assert d.from_step is s + + +def test_deploy_requires_exactly_one_of_image_or_from(): + with pytest.raises(ValueError, match="exactly one of `image=` or `from_=`"): + deploy(port_mapping={5432: port()}) + with pytest.raises(ValueError, match="exactly one of `image=` or `from_=`"): + deploy(image="x", from_=scratch().sh("echo"), port_mapping={5432: port()}) + + +def test_port_mapping_keys_must_be_valid_container_ports(): + with pytest.raises(ValueError, match="port_mapping key must be int in"): + deploy(image="x", port_mapping={0: port()}) + with pytest.raises(ValueError, match="port_mapping key must be int in"): + deploy(image="x", port_mapping={70000: port()}) + with pytest.raises(ValueError, match="port_mapping key must be int in"): + deploy(image="x", port_mapping={"5432": port()}) # type: ignore[dict-item] + + +def test_port_mapping_values_must_be_hm_dev_port(): + with pytest.raises(ValueError, match="port_mapping value must be hm.dev.port"): + deploy(image="x", port_mapping={5432: 31337}) # type: ignore[dict-item] + + +def test_env_values_must_be_strings(): + with pytest.raises(ValueError, match="env value for 'PORT' must be str"): + deploy(image="x", port_mapping={5432: port()}, env={"PORT": 31337}) # type: ignore[dict-item] + + +def test_cmd_coerces_to_tuple_of_strings(): + d = deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", "-c", "shared_buffers=128MB"]) + assert d.cmd == ("postgres", "-c", "shared_buffers=128MB") + + +def test_cmd_rejects_non_string_elements(): + with pytest.raises(ValueError, match="cmd elements must be str"): + deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", 5432]) # type: ignore[list-item] + + +def test_volumes_keys_resolve_relative_to_worktree_at_dump_time(): + # The factory keeps host paths verbatim; resolution happens in + # _registry_dump.py. Here we only check that the dict is preserved. + d = deploy(image="x", port_mapping={5432: port()}, volumes={".": "/workspace"}) + assert dict(d.volumes) == {".": "/workspace"} + + +def test_workdir_must_be_absolute(): + with pytest.raises(ValueError, match="workdir must be an absolute path"): + deploy(image="x", port_mapping={5432: port()}, workdir="workspace") From 4b362ce89483b04e62fa2267fb85eff209fab945 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:47:30 +0000 Subject: [PATCH 10/28] fix(dev): _factory drops dead isinstance check + minor polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught the Pyright "unreachable" on the `isinstance(from_, Step)` guard — Pyright correctly narrows from_ to Step after the preceding XOR check, so the runtime defensive branch is statically dead. Type system already enforces the Step | None contract; drop the check. Step stays imported (still in the type annotation). Plus two minor cleanups: - volumes host-path error now includes type name (consistency with the env / cmd / port_mapping messages). - test rename: test_volumes_keys_resolve_relative_to_worktree... → test_volumes_preserves_host_path_verbatim (the old name implied behavior that lives in _registry_dump, not the factory). Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/_factory.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/harmont/dev/_factory.py b/harmont/dev/_factory.py index a10cac6..525cb18 100644 --- a/harmont/dev/_factory.py +++ b/harmont/dev/_factory.py @@ -40,12 +40,6 @@ def deploy( "`from_=` to build from a Step chain." ) raise ValueError(msg) - if from_ is not None and not isinstance(from_, Step): - msg = ( - f"hm.dev.deploy from_= must be a hm.Step, got {type(from_).__name__}\n" - " → pass a Step chain (e.g. hm.sh(...) or a @hm.target() value)" - ) - raise ValueError(msg) pm = _validate_port_mapping(port_mapping) env_resolved = _validate_env(env) @@ -117,7 +111,7 @@ def _validate_volumes( if not isinstance(hp, str) or not hp: msg = ( f"hm.dev.deploy volumes host path must be a non-empty str, " - f"got {hp!r}" + f"got {hp!r} ({type(hp).__name__})" ) raise ValueError(msg) if not isinstance(cp, str) or not cp.startswith("/"): From d8a9453ffa5874bdd0f2f2c5672b2d91611b69d3 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:47:44 +0000 Subject: [PATCH 11/28] test(dev): rename test_volumes test to reflect actual factory behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old name implied "resolves relative to worktree at dump time" — that behavior lives in _registry_dump.py, not the factory. The factory preserves the host-path string verbatim. Renamed to test_volumes_preserves_host_path_verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/dev/test_deploy_factory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/dev/test_deploy_factory.py b/tests/dev/test_deploy_factory.py index 48ef748..3f93cbc 100644 --- a/tests/dev/test_deploy_factory.py +++ b/tests/dev/test_deploy_factory.py @@ -63,9 +63,9 @@ def test_cmd_rejects_non_string_elements(): deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", 5432]) # type: ignore[list-item] -def test_volumes_keys_resolve_relative_to_worktree_at_dump_time(): - # The factory keeps host paths verbatim; resolution happens in - # _registry_dump.py. Here we only check that the dict is preserved. +def test_volumes_preserves_host_path_verbatim(): + # The factory keeps host paths verbatim; resolution to absolute + # worktree paths happens in _registry_dump.py. d = deploy(image="x", port_mapping={5432: port()}, volumes={".": "/workspace"}) assert dict(d.volumes) == {".": "/workspace"} From 76cea6e9da35d136c519c2e5f16f6425349fb981 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:51:00 +0000 Subject: [PATCH 12/28] feat(deploy): add hm.Dep[T] marker + extend call_with_deps resolver Dep[T] resolves a parameter against harmont._deploy.DEPLOYMENTS by the parameter name (same shape as Target[T] vs _TARGETS_BY_NAME). Cycle detection reuses the existing _RESOLVING stack so dep cycles between deployments and dep cycles between targets share one detector. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/__init__.py | 3 ++- harmont/_deps.py | 21 ++++++++++++++++--- harmont/_typing.py | 19 ++++++++++++++++++ tests/dev/test_dep_marker.py | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 tests/dev/test_dep_marker.py diff --git a/harmont/__init__.py b/harmont/__init__.py index e9d3e3f..f030a14 100644 --- a/harmont/__init__.py +++ b/harmont/__init__.py @@ -33,7 +33,7 @@ from ._envelope import dump_registry_json from ._step import Step, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests -from ._typing import BaseImage, Target +from ._typing import BaseImage, Dep, Target from .cache import ( CacheCompose, CacheForever, @@ -134,6 +134,7 @@ def sh( "CacheOnChange", "CachePolicy", "CacheTTL", + "Dep", "Pipeline", "Step", "Target", diff --git a/harmont/_deps.py b/harmont/_deps.py index a8238fd..7f41444 100644 --- a/harmont/_deps.py +++ b/harmont/_deps.py @@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, Any from ._step import Step -from ._typing import _TARGET_MARKER, _BaseImageMarker +from ._typing import _DEP_MARKER, _TARGET_MARKER, _BaseImageMarker, _DepMarker if TYPE_CHECKING: from collections.abc import Callable @@ -73,8 +73,8 @@ def _param_kind_error(param: inspect.Parameter) -> str | None: def _marker_for(annotation: Any) -> object | None: """Inspect an `Annotated[T, ...]` annotation and return the - hm-specific marker (a `_TargetMarker` or `_BaseImageMarker`) if - present, else None.""" + hm-specific marker (a `_TargetMarker`, `_BaseImageMarker`, or + `_DepMarker`) if present, else None.""" if typing.get_origin(annotation) is None: return None metadata = typing.get_args(annotation)[1:] @@ -83,6 +83,8 @@ def _marker_for(annotation: Any) -> object | None: return _TARGET_MARKER # type: ignore[no-any-return] if isinstance(meta, _BaseImageMarker): return meta + if isinstance(meta, _DepMarker): + return meta return None @@ -158,6 +160,19 @@ def resolve_deps(fn: Callable[..., Any]) -> dict[str, Any]: if isinstance(marker, _BaseImageMarker): kwargs[param.name] = Step(image=marker.image) continue + if isinstance(marker, _DepMarker): + # Local import to avoid circular: _deploy imports nothing from us. + from ._deploy import DEPLOYMENTS + + if param.name not in DEPLOYMENTS: + msg = ( + f"hm.Dep parameter {param.name!r} refers to no registered " + f"@hm.deploy — register one with that slug, or pass " + '`name="..."` to disambiguate.' + ) + raise ValueError(msg) + kwargs[param.name] = DEPLOYMENTS[param.name]() + continue if param.default is not inspect.Parameter.empty: kwargs[param.name] = param.default continue diff --git a/harmont/_typing.py b/harmont/_typing.py index 8a36ffe..47504bf 100644 --- a/harmont/_typing.py +++ b/harmont/_typing.py @@ -95,3 +95,22 @@ def BaseImage(image: str) -> _BaseImageMarker: # noqa: N802 — factory mimicki ) raise TypeError(msg) return _BaseImageMarker(image) + + +class _DepMarker: + """Sentinel for Annotated metadata. Marks a parameter as a + dependency on another @hm.deploy by parameter name. The injected + value is the resolved Deployment. + """ + + __slots__ = () + + def __repr__(self) -> str: + return "" + + +_DEP_MARKER = _DepMarker() + + +# hm.Dep[Deployment] (or a concrete subclass) -> Annotated[T, _DEP_MARKER]. +Dep = Annotated[T, _DEP_MARKER] diff --git a/tests/dev/test_dep_marker.py b/tests/dev/test_dep_marker.py new file mode 100644 index 0000000..9da8abc --- /dev/null +++ b/tests/dev/test_dep_marker.py @@ -0,0 +1,39 @@ +"""hm.Dep[T] marker is detected; call_with_deps resolves it from DEPLOYMENTS.""" +from __future__ import annotations + +import pytest + +from harmont import Dep +from harmont._deploy import DEPLOYMENTS, Deployment +from harmont._deps import call_with_deps + + +def test_dep_marker_alias_subscripts_to_annotated(): + # Dep is PEP-593 Annotated[T, _DEP_MARKER]; subscripting works at + # both static and runtime levels. + from typing import get_args, get_origin + + T = Dep[Deployment] + assert get_origin(T) is not None + args = get_args(T) + assert args[0] is Deployment + + +def test_call_with_deps_resolves_dep_param_from_DEPLOYMENTS(): + # Register a fake deployment under the name "db". + DEPLOYMENTS["db"] = lambda: Deployment(name="db", driver="local") + + def consumer(db: Dep[Deployment]) -> Deployment: + return db + + result = call_with_deps(consumer) + assert isinstance(result, Deployment) + assert result.name == "db" + + +def test_call_with_deps_raises_when_dep_unknown(): + def consumer(redis: Dep[Deployment]) -> Deployment: + return redis + + with pytest.raises(ValueError, match="hm.Dep parameter 'redis' refers to"): + call_with_deps(consumer) From 398f19ac6c3e6ee0abf889a347438bbb1b2b0433 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:53:12 +0000 Subject: [PATCH 13/28] fix(deps): drop unused _DEP_MARKER import Implementer chose isinstance(marker, _DepMarker) over the identity-check pattern (consistent with how _BaseImageMarker is checked), so the _DEP_MARKER instance import is unused and Pyright flags it. Drop it. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/_deps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harmont/_deps.py b/harmont/_deps.py index 7f41444..52f1a6b 100644 --- a/harmont/_deps.py +++ b/harmont/_deps.py @@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, Any from ._step import Step -from ._typing import _DEP_MARKER, _TARGET_MARKER, _BaseImageMarker, _DepMarker +from ._typing import _TARGET_MARKER, _BaseImageMarker, _DepMarker if TYPE_CHECKING: from collections.abc import Callable From 3d65b3396e6d7f9febb67745638eb67bdcf07687 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 19:57:02 +0000 Subject: [PATCH 14/28] fix(deps): Dep error matches Target precedent (TypeError + hm: prefix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught three integration gaps with the existing `_deps.py` machinery: 1. Target "not registered" raises TypeError; the new Dep branch was raising ValueError. Aligned to TypeError. 2. Error-message shape: existing errors are `"hm: 'name' not found\n → fix"`. The Dep error was inline `"hm.Dep parameter ..."` with an em-dash inline fix and a trailing period. Reformatted to match the in-file convention exactly. 3. _DepMarker docstring missing the second-sentence singleton hint that _TargetMarker carries — added. Test updated to expect TypeError + the new message, plus a marker assertion in test_dep_marker_alias_subscripts_to_annotated. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/_deps.py | 8 ++++---- harmont/_typing.py | 8 +++++--- tests/dev/test_dep_marker.py | 5 ++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/harmont/_deps.py b/harmont/_deps.py index 52f1a6b..a806c1f 100644 --- a/harmont/_deps.py +++ b/harmont/_deps.py @@ -166,11 +166,11 @@ def resolve_deps(fn: Callable[..., Any]) -> dict[str, Any]: if param.name not in DEPLOYMENTS: msg = ( - f"hm.Dep parameter {param.name!r} refers to no registered " - f"@hm.deploy — register one with that slug, or pass " - '`name="..."` to disambiguate.' + f"hm: deployment {param.name!r} not found\n" + " → declare it with @hm.deploy() or rename the " + "parameter to match an existing deployment" ) - raise ValueError(msg) + raise TypeError(msg) kwargs[param.name] = DEPLOYMENTS[param.name]() continue if param.default is not inspect.Parameter.empty: diff --git a/harmont/_typing.py b/harmont/_typing.py index 47504bf..953db49 100644 --- a/harmont/_typing.py +++ b/harmont/_typing.py @@ -98,9 +98,11 @@ def BaseImage(image: str) -> _BaseImageMarker: # noqa: N802 — factory mimicki class _DepMarker: - """Sentinel for Annotated metadata. Marks a parameter as a - dependency on another @hm.deploy by parameter name. The injected - value is the resolved Deployment. + """Sentinel class for Annotated metadata. Marks a parameter as a + dependency on another @hm.deploy by parameter name; the injected + value is the resolved Deployment. The module-level instance + ``_DEP_MARKER`` is the actual sentinel value embedded in + ``Annotated[T, _DEP_MARKER]`` by the ``Dep`` alias. """ __slots__ = () diff --git a/tests/dev/test_dep_marker.py b/tests/dev/test_dep_marker.py index 9da8abc..985b1ec 100644 --- a/tests/dev/test_dep_marker.py +++ b/tests/dev/test_dep_marker.py @@ -6,6 +6,7 @@ from harmont import Dep from harmont._deploy import DEPLOYMENTS, Deployment from harmont._deps import call_with_deps +from harmont._typing import _DepMarker def test_dep_marker_alias_subscripts_to_annotated(): @@ -17,6 +18,7 @@ def test_dep_marker_alias_subscripts_to_annotated(): assert get_origin(T) is not None args = get_args(T) assert args[0] is Deployment + assert isinstance(args[1], _DepMarker) def test_call_with_deps_resolves_dep_param_from_DEPLOYMENTS(): @@ -35,5 +37,6 @@ def test_call_with_deps_raises_when_dep_unknown(): def consumer(redis: Dep[Deployment]) -> Deployment: return redis - with pytest.raises(ValueError, match="hm.Dep parameter 'redis' refers to"): + # Matches the Target precedent: TypeError + "hm: 'name' not found". + with pytest.raises(TypeError, match="hm: deployment 'redis' not found"): call_with_deps(consumer) From 06ce0ac73e56fe468a6aad8adee161638c71babf Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:00:13 +0000 Subject: [PATCH 15/28] feat(deploy): add @hm.deploy decorator with slug validation + Dep injection Decorator validates the slug regex (Docker container-name rules), rejects duplicates, validates the function signature via the existing validate_target_signature, and wraps the function so call_with_deps resolves Target/Dep/BaseImage markers at registry-walk time. dataclasses.replace stamps the resolved slug into the returned Deployment so the value seen by callers and the registry has name= (the factory leaves name=""). Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/__init__.py | 6 +- harmont/_deploy.py | 169 +++++++++++++++++++++++++++++++++++- tests/dev/test_decorator.py | 96 ++++++++++++++++++++ 3 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 tests/dev/test_decorator.py diff --git a/harmont/__init__.py b/harmont/__init__.py index f030a14..ae07945 100644 --- a/harmont/__init__.py +++ b/harmont/__init__.py @@ -29,7 +29,8 @@ from typing import TYPE_CHECKING, Any -from . import _decorator +from . import _decorator, dev +from ._deploy import Deployment, deploy from ._envelope import dump_registry_json from ._step import Step, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests @@ -135,12 +136,15 @@ def sh( "CachePolicy", "CacheTTL", "Dep", + "Deployment", "Pipeline", "Step", "Target", "cmake", "compose", "composer", + "deploy", + "dev", "dotnet", "dump_registry_json", "elm", diff --git a/harmont/_deploy.py b/harmont/_deploy.py index 7c8e9a4..37c2de2 100644 --- a/harmont/_deploy.py +++ b/harmont/_deploy.py @@ -8,8 +8,16 @@ """ from __future__ import annotations +import dataclasses +import inspect +import re +import typing from dataclasses import dataclass -from typing import TYPE_CHECKING +from functools import wraps +from typing import TYPE_CHECKING, Any + +from ._deps import call_with_deps +from ._typing import _TARGET_MARKER, _BaseImageMarker, _DepMarker if TYPE_CHECKING: from collections.abc import Callable @@ -29,3 +37,162 @@ class Deployment: # Registry: slug -> zero-arg callable that re-invokes the user-defined # function with deps resolved. Same shape as REGISTRATIONS for pipelines. DEPLOYMENTS: dict[str, Callable[[], Deployment]] = {} + + +_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$") + + +def _validate_slug(slug: str) -> None: + """Raise ValueError if slug does not satisfy Docker container-name rules.""" + if not _SLUG_RE.match(slug): + msg = ( + f"hm: invalid deployment slug {slug!r}\n" + " → use lowercase letters, digits, and '-', " + "start with a letter, max 31 chars (Docker container name rules)" + ) + raise ValueError(msg) + + +def _validate_deploy_signature(fn: Callable[..., Any]) -> None: + """Decoration-time validation for @hm.deploy functions. + + Raises ValueError (not TypeError) with a deploy-specific message so + callers see a clean, deploy-oriented error distinct from the + @hm.target TypeError surface. + + Rules: + - No ``*args``, ``**kwargs``, or positional-only parameters. + - Every parameter must carry an hm marker (``Target[T]``, + ``BaseImage[...]``, ``Dep[T]``) or have a default value. + """ + sig = inspect.signature(fn) + hints: dict[str, Any] + try: + hints = typing.get_type_hints(fn, include_extras=True) + except Exception: + hints = dict(getattr(fn, "__annotations__", {})) + + for param in sig.parameters.values(): + kind = param.kind + if kind == inspect.Parameter.VAR_POSITIONAL: + msg = ( + "hm: @hm.deploy functions cannot take *args\n" + " → declare each dependency as an explicit named parameter" + ) + raise ValueError(msg) + if kind == inspect.Parameter.VAR_KEYWORD: + msg = ( + "hm: @hm.deploy functions cannot take **kwargs\n" + " → declare each dependency as an explicit named parameter" + ) + raise ValueError(msg) + if kind == inspect.Parameter.POSITIONAL_ONLY: + msg = ( + f"hm: @hm.deploy functions cannot have positional-only " + f"parameters (got {param.name!r})\n" + " → remove the '/' marker; parameters must be name-resolvable" + ) + raise ValueError(msg) + + annotation = hints.get(param.name) + # Check for any recognized hm marker. + marker = _marker_for(annotation) + if marker is not None: + continue + if param.default is not inspect.Parameter.empty: + continue + msg = ( + f"hm: parameter {param.name!r} on {fn.__qualname__} must carry a marker\n" + " → annotate with Dep[T] (deployment dep), Target[T] (target dep), " + 'or BaseImage["..."] (scratch image), or give it a default' + ) + raise ValueError(msg) + + +def _marker_for(annotation: Any) -> object | None: + """Return the hm-specific marker from an Annotated annotation, or None.""" + if typing.get_origin(annotation) is None: + return None + metadata = typing.get_args(annotation)[1:] + for meta in metadata: + if meta is _TARGET_MARKER: + return _TARGET_MARKER + if isinstance(meta, _BaseImageMarker): + return meta + if isinstance(meta, _DepMarker): + return meta + return None + + +def deploy( + slug: str | None = None, + *, + name: str | None = None, +) -> "Callable[[Callable[..., Any]], Callable[[], Deployment]]": + """Register a function as a deployment. + + The wrapped function returns a :class:`Deployment` (typically the + output of :func:`harmont.dev.deploy` or any future driver's factory). + Parameters are resolved via the markers used by ``@hm.target`` and + ``@hm.pipeline``, plus ``hm.Dep[T]`` for deployment-to-deployment + references. + + Usage:: + + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy( + image="myapp:latest", + port_mapping={8000: hm.dev.port()}, + env={"DB_HOST": db.name}, + ) + + Args: + slug: Registry key for this deployment. Must match + ``^[a-z][a-z0-9-]{0,30}$`` (Docker container-name rules). + Defaults to the decorated function's ``__name__``. + name: Reserved for future use as a human-readable display name. + Has no effect in v1; the slug is the public identity. + + Raises: + ValueError: On invalid slug, duplicate slug, or an unmarkered + parameter with no default. + TypeError: If the wrapped function returns a non-:class:`Deployment` + value at call time. + """ + + def decorator(fn: "Callable[..., Any]") -> "Callable[[], Deployment]": + _validate_deploy_signature(fn) + resolved_slug = slug if slug is not None else fn.__name__ + _validate_slug(resolved_slug) + if resolved_slug in DEPLOYMENTS: + msg = ( + f"hm: duplicate deployment slug {resolved_slug!r}\n" + " → each @hm.deploy must have a unique slug; pass an " + "explicit slug or `name=\"...\"` to disambiguate" + ) + raise ValueError(msg) + + @wraps(fn) + def wrapper() -> Deployment: + value = call_with_deps(fn) + if not isinstance(value, Deployment): + msg = ( + f"hm.deploy({resolved_slug!r}) must return a Deployment, " + f"got {type(value).__name__}\n" + " → return the output of hm.dev.deploy(...) or another " + "driver's factory" + ) + raise TypeError(msg) + # Stamp the resolved slug into the returned dataclass so callers + # see name= regardless of what the factory left in `name`. + return dataclasses.replace(value, name=resolved_slug) + + DEPLOYMENTS[resolved_slug] = wrapper + return wrapper + + return decorator diff --git a/tests/dev/test_decorator.py b/tests/dev/test_decorator.py new file mode 100644 index 0000000..afdbea9 --- /dev/null +++ b/tests/dev/test_decorator.py @@ -0,0 +1,96 @@ +"""@hm.deploy decorator: registration, slug derivation, fixture injection.""" +from __future__ import annotations + +import pytest + +import harmont as hm +from harmont._deploy import DEPLOYMENTS +from harmont.dev import LocalDeployment + + +def test_deploy_registers_under_explicit_slug(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + assert "db" in DEPLOYMENTS + resolved = DEPLOYMENTS["db"]() + assert isinstance(resolved, LocalDeployment) + assert resolved.name == "db" # decorator stamped slug in + assert resolved.image == "postgres:16" + + +def test_deploy_uses_function_name_when_slug_omitted(): + @hm.deploy() + def redis(): + return hm.dev.deploy(image="redis:7", port_mapping={6379: hm.dev.port()}) + + assert "redis" in DEPLOYMENTS + + +def test_deploy_rejects_invalid_slug(): + with pytest.raises(ValueError, match="invalid deployment slug"): + @hm.deploy("Bad Slug") + def x(): + return hm.dev.deploy(image="x", port_mapping={5432: hm.dev.port()}) + + +def test_deploy_rejects_duplicate_slug(): + @hm.deploy("db") + def db1(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + with pytest.raises(ValueError, match="duplicate deployment slug"): + @hm.deploy("db") + def db2(): + return hm.dev.deploy(image="postgres:15", port_mapping={5432: hm.dev.port()}) + + +def test_deploy_requires_marker_on_param(): + with pytest.raises(ValueError, match=r"parameter 'db' on .* must carry a marker"): + @hm.deploy("api") + def api(db): # type: ignore[no-untyped-def] + return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) + + +def test_deploy_injects_dep_value(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + # db.name comes from the resolved upstream Deployment + return hm.dev.deploy( + image="x", + port_mapping={8000: hm.dev.port()}, + env={"DB_HOST": db.name}, + ) + + resolved = DEPLOYMENTS["api"]() + assert resolved.env["DB_HOST"] == "db" + + +def test_deploy_with_explicit_name_arg(): + @hm.deploy("db", name="primary-db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + # The display name is held alongside the slug; the registry is keyed by slug. + assert "db" in DEPLOYMENTS + # In v1 we don't expose `name` separately on the returned Deployment; + # the slug IS the public identity. The kwarg is reserved for future use. + + +def test_deploy_function_can_return_remote_driver_value(): + # Simulate a future driver: a function that returns a Deployment with + # driver != "local". The decorator must register it without complaint. + from harmont._deploy import Deployment + + @hm.deploy("prod-api") + def prod_api(): + return Deployment(name="", driver="aws") + + resolved = DEPLOYMENTS["prod-api"]() + assert resolved.driver == "aws" + assert resolved.name == "prod-api" From 4c1073dfd13be873fb67a24f01b5ce9b56238fea Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:03:10 +0000 Subject: [PATCH 16/28] fix(deploy): @hm.deploy uses shared validate_target_signature Spec reviewer caught two duplications in the prior commit: 1. _validate_deploy_signature was a deploy-specific re-implementation of the shared validate_target_signature from _deps.py. The plan mandated calling the shared validator. The variant existed only to raise ValueError instead of TypeError to match a test expectation that was itself inconsistent with Task 5's TypeError precedent for marker errors. 2. _marker_for was copied verbatim from _deps.py to support the variant validator. Both deleted. validate_target_signature is now imported from _deps and called directly. test_deploy_requires_marker_on_param updated to expect TypeError + the actual validator's "has no marker" message. `name=` kwarg is now explicitly `del name`'d to silence Pyright's "not accessed" hint without losing the future-API kwarg shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/_deploy.py | 97 +++++-------------------------------- tests/dev/test_decorator.py | 4 +- 2 files changed, 14 insertions(+), 87 deletions(-) diff --git a/harmont/_deploy.py b/harmont/_deploy.py index 37c2de2..bed5a4a 100644 --- a/harmont/_deploy.py +++ b/harmont/_deploy.py @@ -9,15 +9,12 @@ from __future__ import annotations import dataclasses -import inspect import re -import typing from dataclasses import dataclass from functools import wraps from typing import TYPE_CHECKING, Any -from ._deps import call_with_deps -from ._typing import _TARGET_MARKER, _BaseImageMarker, _DepMarker +from ._deps import call_with_deps, validate_target_signature if TYPE_CHECKING: from collections.abc import Callable @@ -53,77 +50,6 @@ def _validate_slug(slug: str) -> None: raise ValueError(msg) -def _validate_deploy_signature(fn: Callable[..., Any]) -> None: - """Decoration-time validation for @hm.deploy functions. - - Raises ValueError (not TypeError) with a deploy-specific message so - callers see a clean, deploy-oriented error distinct from the - @hm.target TypeError surface. - - Rules: - - No ``*args``, ``**kwargs``, or positional-only parameters. - - Every parameter must carry an hm marker (``Target[T]``, - ``BaseImage[...]``, ``Dep[T]``) or have a default value. - """ - sig = inspect.signature(fn) - hints: dict[str, Any] - try: - hints = typing.get_type_hints(fn, include_extras=True) - except Exception: - hints = dict(getattr(fn, "__annotations__", {})) - - for param in sig.parameters.values(): - kind = param.kind - if kind == inspect.Parameter.VAR_POSITIONAL: - msg = ( - "hm: @hm.deploy functions cannot take *args\n" - " → declare each dependency as an explicit named parameter" - ) - raise ValueError(msg) - if kind == inspect.Parameter.VAR_KEYWORD: - msg = ( - "hm: @hm.deploy functions cannot take **kwargs\n" - " → declare each dependency as an explicit named parameter" - ) - raise ValueError(msg) - if kind == inspect.Parameter.POSITIONAL_ONLY: - msg = ( - f"hm: @hm.deploy functions cannot have positional-only " - f"parameters (got {param.name!r})\n" - " → remove the '/' marker; parameters must be name-resolvable" - ) - raise ValueError(msg) - - annotation = hints.get(param.name) - # Check for any recognized hm marker. - marker = _marker_for(annotation) - if marker is not None: - continue - if param.default is not inspect.Parameter.empty: - continue - msg = ( - f"hm: parameter {param.name!r} on {fn.__qualname__} must carry a marker\n" - " → annotate with Dep[T] (deployment dep), Target[T] (target dep), " - 'or BaseImage["..."] (scratch image), or give it a default' - ) - raise ValueError(msg) - - -def _marker_for(annotation: Any) -> object | None: - """Return the hm-specific marker from an Annotated annotation, or None.""" - if typing.get_origin(annotation) is None: - return None - metadata = typing.get_args(annotation)[1:] - for meta in metadata: - if meta is _TARGET_MARKER: - return _TARGET_MARKER - if isinstance(meta, _BaseImageMarker): - return meta - if isinstance(meta, _DepMarker): - return meta - return None - - def deploy( slug: str | None = None, *, @@ -133,9 +59,8 @@ def deploy( The wrapped function returns a :class:`Deployment` (typically the output of :func:`harmont.dev.deploy` or any future driver's factory). - Parameters are resolved via the markers used by ``@hm.target`` and - ``@hm.pipeline``, plus ``hm.Dep[T]`` for deployment-to-deployment - references. + Parameters are resolved via the shared marker machinery: ``Target[T]``, + ``BaseImage[...]``, and ``Dep[T]`` (deployment-to-deployment refs). Usage:: @@ -152,21 +77,21 @@ def api(db: hm.Dep[hm.Deployment]): ) Args: - slug: Registry key for this deployment. Must match - ``^[a-z][a-z0-9-]{0,30}$`` (Docker container-name rules). - Defaults to the decorated function's ``__name__``. + slug: Registry key. Must match ``^[a-z][a-z0-9-]{0,30}$`` + (Docker container-name rules). Defaults to ``fn.__name__``. name: Reserved for future use as a human-readable display name. Has no effect in v1; the slug is the public identity. Raises: - ValueError: On invalid slug, duplicate slug, or an unmarkered - parameter with no default. - TypeError: If the wrapped function returns a non-:class:`Deployment` - value at call time. + ValueError: On invalid or duplicate slug. + TypeError: On unmarkered parameters without defaults (raised by + the shared :func:`validate_target_signature`), or if + the wrapped function returns a non-Deployment value. """ + del name # reserved-for-future-use; explicitly drop the unused binding def decorator(fn: "Callable[..., Any]") -> "Callable[[], Deployment]": - _validate_deploy_signature(fn) + validate_target_signature(fn) resolved_slug = slug if slug is not None else fn.__name__ _validate_slug(resolved_slug) if resolved_slug in DEPLOYMENTS: diff --git a/tests/dev/test_decorator.py b/tests/dev/test_decorator.py index afdbea9..1036d21 100644 --- a/tests/dev/test_decorator.py +++ b/tests/dev/test_decorator.py @@ -47,7 +47,9 @@ def db2(): def test_deploy_requires_marker_on_param(): - with pytest.raises(ValueError, match=r"parameter 'db' on .* must carry a marker"): + # validate_target_signature (the shared validator used by @hm.target, + # @hm.pipeline, and @hm.deploy) raises TypeError for unmarkered params. + with pytest.raises(TypeError, match=r"parameter 'db' has no marker"): @hm.deploy("api") def api(db): # type: ignore[no-untyped-def] return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) From cf615dc811176233eaab6f2cc0cedd17c238f3f7 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:04:40 +0000 Subject: [PATCH 17/28] fix(deploy): duplicate-slug error drops misleading name= hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer noted the duplicate-slug message suggested passing `name="..."` to disambiguate, but `name=` is reserved-for-future (currently a no-op via `del name`). Pointing users at it is misleading. The actual fix is to pass an explicit `slug=` — that's now what the error says. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/_deploy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/harmont/_deploy.py b/harmont/_deploy.py index bed5a4a..a71e9ec 100644 --- a/harmont/_deploy.py +++ b/harmont/_deploy.py @@ -97,8 +97,8 @@ def decorator(fn: "Callable[..., Any]") -> "Callable[[], Deployment]": if resolved_slug in DEPLOYMENTS: msg = ( f"hm: duplicate deployment slug {resolved_slug!r}\n" - " → each @hm.deploy must have a unique slug; pass an " - "explicit slug or `name=\"...\"` to disambiguate" + " → each @hm.deploy must have a unique slug; " + "pass an explicit slug= to disambiguate" ) raise ValueError(msg) From 8ac9e749c86679b618e1aff4aeacaf18f5249744 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:06:39 +0000 Subject: [PATCH 18/28] feat(deploy): add dep_graph + topo_order over DEPLOYMENTS dep_graph walks the registry, introspects each wrapped function for Dep[T] params, and emits slug -> tuple of upstream slugs in parameter order. topo_order runs Kahn's algorithm with stable level ordering (insertion order within a level) so the registry-dump output is deterministic. Cycle detection raises RuntimeError listing the unresolved slugs. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/_deploy.py | 66 ++++++++++++++++++++++++++++++++++++++++++ tests/dev/test_topo.py | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/dev/test_topo.py diff --git a/harmont/_deploy.py b/harmont/_deploy.py index a71e9ec..fb3913e 100644 --- a/harmont/_deploy.py +++ b/harmont/_deploy.py @@ -121,3 +121,69 @@ def wrapper() -> Deployment: return wrapper return decorator + + +def dep_graph() -> dict[str, tuple[str, ...]]: + """Return slug -> tuple of upstream slugs, in parameter order. + + Walks DEPLOYMENTS; for each registered slug, introspects the wrapped + function's signature for ``Dep[T]`` parameters. Plain defaults and + Target/BaseImage markers do not produce edges in the deploy graph. + """ + import inspect + import typing as _typing + + from ._typing import _DepMarker + + out: dict[str, tuple[str, ...]] = {} + for slug, wrapper in DEPLOYMENTS.items(): + fn = wrapper.__wrapped__ # type: ignore[attr-defined] + sig = inspect.signature(fn) + hints = _typing.get_type_hints(fn, include_extras=True) + deps: list[str] = [] + for name in sig.parameters: + ann = hints.get(name) + if ann is None: + continue + if _typing.get_origin(ann) is None: + continue + metadata = _typing.get_args(ann)[1:] + if any(isinstance(m, _DepMarker) for m in metadata): + deps.append(name) + out[slug] = tuple(deps) + return out + + +def topo_order() -> list[str]: + """Topological ordering of DEPLOYMENTS by dep_graph; deps first. + + Raises RuntimeError on cycles. Stable under insertion order for + independent slugs (preserves decoration order within a level). + """ + g = dep_graph() + # Kahn's algorithm w/ stable level ordering (insertion-order of g). + indeg: dict[str, int] = {} + for slug, upstreams in g.items(): + indeg[slug] = sum(1 for u in upstreams if u in g) + order: list[str] = [] + while True: + progressed = False + for slug in list(g.keys()): + if slug in order: + continue + if indeg[slug] == 0: + order.append(slug) + for downstream, upstreams in g.items(): + if slug in upstreams and downstream not in order: + indeg[downstream] -= 1 + progressed = True + if not progressed: + break + if len(order) != len(g): + unresolved = [s for s in g if s not in order] + msg = ( + f"hm: dep cycle among deployments: {', '.join(unresolved)}\n" + " → break the cycle, or factor shared state into a target" + ) + raise RuntimeError(msg) + return order diff --git a/tests/dev/test_topo.py b/tests/dev/test_topo.py new file mode 100644 index 0000000..17cd4fa --- /dev/null +++ b/tests/dev/test_topo.py @@ -0,0 +1,63 @@ +"""dep_graph extraction + topo_order on the deployment registry.""" +from __future__ import annotations + +import pytest + +import harmont as hm +from harmont._deploy import dep_graph, topo_order + + +def test_dep_graph_empty_when_no_deps(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + g = dep_graph() + assert g == {"db": ()} + + +def test_dep_graph_lists_param_names_in_order(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}, + env={"DB": db.name}) + + g = dep_graph() + assert g == {"db": (), "api": ("db",)} + + +def test_topo_order_is_stable_and_deps_first(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) + + @hm.deploy("web") + def web(api: hm.Dep[hm.Deployment]): + return hm.dev.deploy(image="x", port_mapping={3000: hm.dev.port()}) + + order = topo_order() + # db before api before web + assert order.index("db") < order.index("api") < order.index("web") + + +def test_topo_order_raises_on_cycle(): + from harmont._deploy import Deployment + + @hm.deploy("a") + def a(b: hm.Dep[hm.Deployment]): + return Deployment(name="", driver="local") + + @hm.deploy("b") + def b(a: hm.Dep[hm.Deployment]): + return Deployment(name="", driver="local") + + with pytest.raises(RuntimeError, match="dep cycle"): + topo_order() From eb73876ae9d3c514861f505e2e846a15e491def9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:11:57 +0000 Subject: [PATCH 19/28] feat(dev): dump_registry_json emits the v0 deployment IR for the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks DEPLOYMENTS in topo order, lowering LocalDeployment values to the schema documented in the spec (§ 1). Step-chain from_= values are lowered via the existing harmont.pipeline() + keygen pipeline so the Rust executor can run the chain and use the terminal key as the build-image tag. Non-local drivers are passed through as {"driver": X, "_unhandled": true} for hm dev ls. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__init__.py | 7 ++- harmont/dev/_registry_dump.py | 104 ++++++++++++++++++++++++++++++++ tests/dev/test_registry_dump.py | 93 ++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 harmont/dev/_registry_dump.py create mode 100644 tests/dev/test_registry_dump.py diff --git a/harmont/dev/__init__.py b/harmont/dev/__init__.py index c9b8d15..060c33e 100644 --- a/harmont/dev/__init__.py +++ b/harmont/dev/__init__.py @@ -1,18 +1,19 @@ """harmont.dev — local Docker deployment driver. -Public surface (grows across tasks): +Public surface: deploy(*, image=None, from_=None, cmd=None, port_mapping=None, env=None, volumes=None, workdir=None) -> LocalDeployment port() -> _PortSentinel LocalDeployment (concrete subclass) - dump_registry_json() -> str (Task 8) + dump_registry_json(*, worktree_root) -> str """ from __future__ import annotations from ._deployment import LocalDeployment from ._factory import deploy from ._port import port +from ._registry_dump import dump_registry_json -__all__ = ["LocalDeployment", "deploy", "port"] +__all__ = ["LocalDeployment", "deploy", "dump_registry_json", "port"] diff --git a/harmont/dev/_registry_dump.py b/harmont/dev/_registry_dump.py new file mode 100644 index 0000000..86f6063 --- /dev/null +++ b/harmont/dev/_registry_dump.py @@ -0,0 +1,104 @@ +"""Local-driver registry dump. + +Walks ``harmont._deploy.DEPLOYMENTS`` in topo order, lowering each +``LocalDeployment`` to the JSON shape described in +``docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md`` § 1. +Non-local deployments are passed through as ``{"driver": X, +"_unhandled": true}`` so the CLI can render them in ``hm dev ls``. + +Step-chain deployments emit their pipeline as the existing v0 IR via +``harmont.pipeline()``; cache-keys are resolved through the standard +keygen path so the Rust executor can use the terminal key as the +build-image tag without re-running the algorithm. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from harmont._deploy import DEPLOYMENTS, Deployment, dep_graph, topo_order +from harmont._target import clear_target_memo +from harmont.keygen import resolve_pipeline_keys +from harmont.pipeline import pipeline as _assemble + +from ._deployment import LocalDeployment +from ._port import _PortSentinel + +if TYPE_CHECKING: + pass + + +_SENTINEL_WIRE = "__hm_dev_port__" + + +def _lower_local(d: LocalDeployment, deps: tuple[str, ...]) -> dict[str, Any]: + return { + "driver": "local", + "image": d.image, + "from": _lower_from_step(d.from_step) if d.from_step is not None else None, + "cmd": list(d.cmd) if d.cmd is not None else None, + "port_mapping": { + str(cport): _SENTINEL_WIRE + for cport, value in d.port_mapping.items() + if isinstance(value, _PortSentinel) + }, + "env": dict(d.env), + "volumes": dict(d.volumes), + "workdir": d.workdir, + "deps": list(deps), + } + + +def _lower_from_step(step: Any) -> dict[str, Any]: + """Lower a single Step (the deployment's `from_=`) into the v0 IR shape. + + The Step is treated as the terminal leaf of a one-pipeline IR. + Cache-keys are resolved via the existing keygen so the Rust side + can use them as image tags without re-running the algorithm. + """ + ir = _assemble(step) + resolve_pipeline_keys( + ir.get("steps", []), + pipeline_org="hm-dev", + pipeline_slug="hm-dev-build", + now=0, + base_path=Path("/tmp"), + env={}, + ) + return {"type": "step_chain", "pipeline_v0": ir} + + +def dump_registry_json( + *, + worktree_root: "Path | None" = None, +) -> str: + """Emit the v0 deployment-registry JSON. + + ``worktree_root`` is recorded so the CLI can resolve relative + ``volumes`` paths and the worktree-hash label. Pass the value + yourself in tests; production use comes through the CLI shim + (``python -m harmont.dev --dump-registry --worktree-root ``). + """ + clear_target_memo() + wt = Path(worktree_root) if worktree_root is not None else Path.cwd() + order = topo_order() + graph = dep_graph() + deployments: dict[str, dict[str, Any]] = {} + for slug in order: + value = DEPLOYMENTS[slug]() + if isinstance(value, LocalDeployment): + deployments[slug] = _lower_local(value, graph[slug]) + elif isinstance(value, Deployment): + deployments[slug] = {"driver": value.driver, "_unhandled": True} + else: + msg = ( + f"hm: @hm.deploy({slug!r}) returned {type(value).__name__}; " + "expected a Deployment subclass" + ) + raise TypeError(msg) + return json.dumps({ + "schema_version": "0", + "worktree": str(wt), + "deployments": deployments, + }) diff --git a/tests/dev/test_registry_dump.py b/tests/dev/test_registry_dump.py new file mode 100644 index 0000000..ce1d79b --- /dev/null +++ b/tests/dev/test_registry_dump.py @@ -0,0 +1,93 @@ +"""dump_registry_json — golden JSON shape for canonical examples.""" +from __future__ import annotations + +import json +from pathlib import Path + +import harmont as hm +from harmont._deploy import Deployment +from harmont.dev import dump_registry_json + + +def test_dump_minimal_local_deployment(): + @hm.deploy("db") + def db(): + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + assert out["schema_version"] == "0" + assert out["worktree"] == "/tmp/wt" + assert out["deployments"]["db"] == { + "driver": "local", + "image": "postgres:16", + "from": None, + "cmd": None, + "port_mapping": {"5432": "__hm_dev_port__"}, + "env": {"POSTGRES_PASSWORD": "dev"}, + "volumes": {}, + "workdir": None, + "deps": [], + } + + +def test_dump_with_cmd_workdir_volumes(): + @hm.deploy("db") + def db(): + return hm.dev.deploy( + image="postgres:16", + cmd=["postgres", "-c", "shared_buffers=128MB"], + port_mapping={5432: hm.dev.port()}, + volumes={".": "/workspace"}, + workdir="/workspace", + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + e = out["deployments"]["db"] + assert e["cmd"] == ["postgres", "-c", "shared_buffers=128MB"] + assert e["workdir"] == "/workspace" + assert e["volumes"] == {".": "/workspace"} + + +def test_dump_with_deps_emits_deps_array_in_param_order(): + @hm.deploy("db") + def db(): + return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) + + @hm.deploy("api") + def api(db: hm.Dep[hm.Deployment]): + return hm.dev.deploy( + image="x", port_mapping={8000: hm.dev.port()}, + env={"DB_HOST": db.name}, + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + assert out["deployments"]["api"]["deps"] == ["db"] + assert out["deployments"]["api"]["env"] == {"DB_HOST": "db"} + + +def test_dump_step_chain_emits_pipeline_v0_ir(): + @hm.deploy("api") + def api(): + return hm.dev.deploy( + from_=hm.sh("echo build", image="alpine:3.20"), + port_mapping={8000: hm.dev.port()}, + ) + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + f = out["deployments"]["api"]["from"] + assert f["type"] == "step_chain" + assert f["pipeline_v0"]["version"] == "0" + assert f["pipeline_v0"]["steps"][0]["cmd"] == "echo build" + + +def test_dump_non_local_driver_is_marked_unhandled(): + @hm.deploy("prod-api") + def prod_api(): + return Deployment(name="", driver="aws") + + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + assert out["deployments"]["prod-api"] == {"driver": "aws", "_unhandled": True} From 3d70d6eda30e8b436330db6d509a6fcaf8537952 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:15:00 +0000 Subject: [PATCH 20/28] fix(dev): drop dead TYPE_CHECKING import in _registry_dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught the leftover `if TYPE_CHECKING: pass` scaffolding (with the TYPE_CHECKING import) — dead code from the plan-quoted template. None of the imports under TYPE_CHECKING were needed. Removed both the import and the block. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/_registry_dump.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/harmont/dev/_registry_dump.py b/harmont/dev/_registry_dump.py index 86f6063..ffca9c3 100644 --- a/harmont/dev/_registry_dump.py +++ b/harmont/dev/_registry_dump.py @@ -15,7 +15,7 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from harmont._deploy import DEPLOYMENTS, Deployment, dep_graph, topo_order from harmont._target import clear_target_memo @@ -25,9 +25,6 @@ from ._deployment import LocalDeployment from ._port import _PortSentinel -if TYPE_CHECKING: - pass - _SENTINEL_WIRE = "__hm_dev_port__" From 2691e7828f17d9bcd62ee878e5c1b8cf68057ba6 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:18:01 +0000 Subject: [PATCH 21/28] feat(dev): python -m harmont.dev --dump-registry CLI shim Walks .harmont/*.py, imports each by file path so @hm.deploy registrations land in harmont._deploy.DEPLOYMENTS, then prints the deployment registry JSON to stdout. The Rust CLI invokes this and deserializes via serde (see harmont-cli plan). Missing .harmont/ exits 1 with a fix-directed stderr. Argparse handles usage errors with exit 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__main__.py | 71 ++++++++++++++++++++++++++++++++++++++ tests/dev/test_dump_cli.py | 57 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 harmont/dev/__main__.py create mode 100644 tests/dev/test_dump_cli.py diff --git a/harmont/dev/__main__.py b/harmont/dev/__main__.py new file mode 100644 index 0000000..625051d --- /dev/null +++ b/harmont/dev/__main__.py @@ -0,0 +1,71 @@ +"""`python -m harmont.dev` — registry-dump entry point for the CLI. + +Walks ``.harmont/*.py`` (importing each by file path), letting +``@hm.deploy``-decorated functions register themselves into +``harmont._deploy.DEPLOYMENTS`` as a side effect. Then emits the +deployment registry JSON to stdout. + +Errors go to stderr with exit code 1 (DSL error) or 2 (argparse +usage error), matching ``harmont``'s convention. +""" +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path + + +def _import_path(path: Path) -> None: + spec = importlib.util.spec_from_file_location( + name=f"_harmont_dev_user_{path.stem}", + location=str(path), + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load module from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + +def _walk_harmont_dir(root: Path) -> None: + harmont_dir = root / ".harmont" + if not harmont_dir.is_dir(): + print( + f"hm: no .harmont/ directory in {root}\n" + " → create .harmont/ and add @hm.deploy-decorated functions", + file=sys.stderr, + ) + sys.exit(1) + for py in sorted(harmont_dir.glob("*.py")): + _import_path(py) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m harmont.dev") + parser.add_argument( + "--dump-registry", + action="store_true", + help="walk .harmont/*.py and emit the v0 deployment registry JSON", + ) + parser.add_argument( + "--worktree-root", + type=Path, + default=None, + help="path to the worktree root; defaults to cwd", + ) + args = parser.parse_args(argv) + + if not args.dump_registry: + parser.error("nothing to do; pass --dump-registry") + return 2 + + from harmont.dev import dump_registry_json + + root = args.worktree_root if args.worktree_root is not None else Path.cwd() + _walk_harmont_dir(root) + print(dump_registry_json(worktree_root=root)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/dev/test_dump_cli.py b/tests/dev/test_dump_cli.py new file mode 100644 index 0000000..2b3fd17 --- /dev/null +++ b/tests/dev/test_dump_cli.py @@ -0,0 +1,57 @@ +"""`python -m harmont.dev --dump-registry` integration.""" +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap +from pathlib import Path + + +def test_dump_cli_walks_harmont_dir_and_prints_registry(tmp_path: Path): + pkg = tmp_path / ".harmont" + pkg.mkdir() + (pkg / "deploys.py").write_text(textwrap.dedent(""" + import harmont as hm + + @hm.deploy("db") + def db(): + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + """)) + result = subprocess.run( + [sys.executable, "-m", "harmont.dev", "--dump-registry"], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + out = json.loads(result.stdout) + assert out["schema_version"] == "0" + assert out["worktree"] == str(tmp_path) + assert "db" in out["deployments"] + assert out["deployments"]["db"]["image"] == "postgres:16" + + +def test_dump_cli_errors_when_no_harmont_dir(tmp_path: Path): + result = subprocess.run( + [sys.executable, "-m", "harmont.dev", "--dump-registry"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "no .harmont/ directory" in result.stderr + + +def test_dump_cli_errors_on_bad_argument(tmp_path: Path): + result = subprocess.run( + [sys.executable, "-m", "harmont.dev", "--no-such-flag"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert result.returncode == 2 # argparse default From 05fd295f206223bb6616bc74242cd8193bdcd6b2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:21:13 +0000 Subject: [PATCH 22/28] fix(dev): drop dead `return 2` after parser.error() parser.error() is typed NoReturn (calls sys.exit(2) internally), so the trailing return was unreachable. Removed; added a one-line comment noting the NoReturn so future readers don't reintroduce it. Co-Authored-By: Claude Opus 4.7 (1M context) --- harmont/dev/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harmont/dev/__main__.py b/harmont/dev/__main__.py index 625051d..c4fea11 100644 --- a/harmont/dev/__main__.py +++ b/harmont/dev/__main__.py @@ -56,8 +56,8 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) if not args.dump_registry: + # parser.error() is NoReturn (calls sys.exit(2)); execution stops here. parser.error("nothing to do; pass --dump-registry") - return 2 from harmont.dev import dump_registry_json From 023801265b49f41767bc20d7c442f546f2744640 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:21:47 +0000 Subject: [PATCH 23/28] docs: document hm.deploy + hm.dev in CLAUDE.md Adds the deployments section to the agent-facing doc with the canonical example and the full public surface. Cross-links the design spec for engineers who need the wire-format details. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b492573..b0e815a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,61 @@ Memoization scope is one `dump_registry_json` render. Two targets that both depend on `apt_base` share the same `Step`, so the v0 IR contains one apt-base step with N children — not N copies. +## Deployments — `@hm.deploy` and `hm.dev` + +`@hm.deploy` is a driver-agnostic decorator that registers a function +as a long-lived service. The function returns a `Deployment` value +produced by a driver-specific factory; v1 ships only the local Docker +driver via `hm.dev.deploy(...)`. Future cloud drivers (`hm.aws.deploy`, +`hm.fly.deploy`) plug in without touching the top-level decorator. + +```python +import harmont as hm + +@hm.deploy("db") +def db() -> hm.Deployment: + return hm.dev.deploy( + image="postgres:16", + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + +@hm.deploy("api") +def api( + db: hm.Dep[hm.Deployment], + api_image: hm.Target[hm.Step], +) -> hm.Deployment: + return hm.dev.deploy( + from_=api_image, + port_mapping={8000: hm.dev.port()}, + env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, + ) +``` + +Public surface: + +```python +hm.deploy(slug=None, *, name=None) # decorator +hm.Dep[T] # PEP-593 fixture marker +hm.Deployment # abstract dataclass + +hm.dev.deploy(*, image=None, from_=None, cmd=None, + port_mapping=None, env=None, + volumes=None, workdir=None) # -> LocalDeployment +hm.dev.port() # OS-assigned host port sentinel +hm.dev.LocalDeployment # concrete subclass +hm.dev.dump_registry_json(*, worktree_root) # -> v0 JSON +``` + +`hm.dev.port()` is only valid as a value in `port_mapping`. The host +port is assigned by Docker (via `-p :`) at `hm dev up` +time; query it from another terminal with `hm dev port-of +`. Ports are fresh on every `hm dev up`. + +The Rust CLI (`hm dev up`) shells out to `python -m harmont.dev +--dump-registry` to obtain the registry JSON. Schema is at +`docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md` § 1. + ## Cache keys `harmont.keygen.resolve_pipeline_keys` ports the algorithm previously From 379cece700f623524cc0e5eac7d3f7e64e568f1f Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 21 May 2026 20:25:12 +0000 Subject: [PATCH 24/28] test(dev): end-to-end canonical db+api+web example Mirrors the spec's worked example. Asserts topo order, dep edges, cross-deploy f-string env values, and that from_=Step lowers through the existing v0 IR pipeline. This is the "vibe check" gate before the CLI plan can start consuming the JSON output. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/dev/test_canonical_example.py | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/dev/test_canonical_example.py diff --git a/tests/dev/test_canonical_example.py b/tests/dev/test_canonical_example.py new file mode 100644 index 0000000..7ec8784 --- /dev/null +++ b/tests/dev/test_canonical_example.py @@ -0,0 +1,61 @@ +"""End-to-end test mirroring the spec's canonical db+api+web example.""" +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import harmont as hm + +if TYPE_CHECKING: + from pathlib import Path + + +def test_canonical_db_api_web_dumps_expected_shape(tmp_path: Path): + @hm.target() + def api_image() -> hm.Step: + return hm.sh("docker build -t myapi .", image="docker:24") + + @hm.deploy("db") + def db() -> hm.Deployment: + return hm.dev.deploy( + image="postgres:16", + cmd=["postgres", "-c", "shared_buffers=128MB"], + port_mapping={5432: hm.dev.port()}, + env={"POSTGRES_PASSWORD": "dev"}, + ) + + @hm.deploy("api") + def api( + db: hm.Dep[hm.Deployment], + api_image: hm.Target[hm.Step], + ) -> hm.Deployment: + return hm.dev.deploy( + from_=api_image, + port_mapping={8000: hm.dev.port()}, + env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, + volumes={".": "/workspace"}, + workdir="/workspace", + ) + + @hm.deploy("web") + def web(api: hm.Dep[hm.Deployment]) -> hm.Deployment: + return hm.dev.deploy( + image="node:20", + port_mapping={3000: hm.dev.port()}, + env={"API_URL": f"http://{api.name}:8000"}, + ) + + raw = hm.dev.dump_registry_json(worktree_root=tmp_path) + out = json.loads(raw) + assert out["schema_version"] == "0" + assert list(out["deployments"].keys()) == ["db", "api", "web"] # topo order + assert out["deployments"]["api"]["deps"] == ["db"] + assert out["deployments"]["web"]["deps"] == ["api"] + assert out["deployments"]["api"]["env"]["DATABASE_URL"] == "postgres://db:5432/app" + assert out["deployments"]["web"]["env"]["API_URL"] == "http://api:8000" + # Step-chain `from_=` lowered through the existing v0 IR machinery + api_from = out["deployments"]["api"]["from"] + assert api_from["type"] == "step_chain" + assert api_from["pipeline_v0"]["version"] == "0" + assert any(s.get("cmd", "").startswith("docker build") + for s in api_from["pipeline_v0"]["steps"]) From 05400257753138bef48d63dc7aad497528d53f43 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 22 May 2026 02:55:57 +0000 Subject: [PATCH 25/28] docs(deploy): swap canonical example to python -m http.server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous canonical example used postgres:16 + a Step-chain api build + node:20 — three heavy images and a build path that v1 cli stubs out. The hello+greeter pair now runs `python -m http.server` from `python:3.12-alpine` (the Python stdlib's built-in HTTP server; no third-party image dependency). Same surface coverage (@hm.deploy, hm.Dep[T], cross-deploy env interpolation), much smaller footprint. Updated the design spec § 1 + § 6, CLAUDE.md, the py plan's Task 11, and tests/dev/test_canonical_example.py to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 24 +++---- .../plans/2026-05-21-hm-dev-deploy-py.md | 72 ++++++------------- .../specs/2026-05-21-hm-dev-deploy-design.md | 50 ++++++------- tests/dev/test_canonical_example.py | 68 +++++++----------- 4 files changed, 83 insertions(+), 131 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0e815a..36848b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,23 +203,21 @@ driver via `hm.dev.deploy(...)`. Future cloud drivers (`hm.aws.deploy`, ```python import harmont as hm -@hm.deploy("db") -def db() -> hm.Deployment: +@hm.deploy("hello") +def hello() -> hm.Deployment: return hm.dev.deploy( - image="postgres:16", - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, ) -@hm.deploy("api") -def api( - db: hm.Dep[hm.Deployment], - api_image: hm.Target[hm.Step], -) -> hm.Deployment: +@hm.deploy("greeter") +def greeter(hello: hm.Dep[hm.Deployment]) -> hm.Deployment: return hm.dev.deploy( - from_=api_image, - port_mapping={8000: hm.dev.port()}, - env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, + env={"HELLO_HOST": hello.name}, ) ``` diff --git a/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md b/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md index eb7f644..126063e 100644 --- a/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md +++ b/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md @@ -2206,64 +2206,38 @@ A final integration test that mirrors the spec's canonical example. Confirms eve In `tests/dev/test_canonical_example.py`: ```python -"""End-to-end test mirroring the spec's canonical db+api+web example.""" -from __future__ import annotations - -import json -from pathlib import Path - -import harmont as hm - - -def test_canonical_db_api_web_dumps_expected_shape(): - @hm.target() - def api_image() -> hm.Step: - return hm.sh("docker build -t myapi .", image="docker:24") - - @hm.deploy("db") - def db() -> hm.Deployment: +def test_canonical_hello_greeter_dumps_expected_shape(): + @hm.deploy("hello") + def hello() -> hm.Deployment: return hm.dev.deploy( - image="postgres:16", - cmd=["postgres", "-c", "shared_buffers=128MB"], - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, ) - @hm.deploy("api") - def api( - db: hm.Dep[hm.Deployment], - api_image: hm.Target[hm.Step], - ) -> hm.Deployment: + @hm.deploy("greeter") + def greeter(hello: hm.Dep[hm.Deployment]) -> hm.Deployment: return hm.dev.deploy( - from_=api_image, - port_mapping={8000: hm.dev.port()}, - env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, - volumes={".": "/workspace"}, - workdir="/workspace", - ) - - @hm.deploy("web") - def web(api: hm.Dep[hm.Deployment]) -> hm.Deployment: - return hm.dev.deploy( - image="node:20", - port_mapping={3000: hm.dev.port()}, - env={"API_URL": f"http://{api.name}:8000"}, + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, + env={"HELLO_HOST": hello.name}, ) raw = hm.dev.dump_registry_json(worktree_root=Path("/tmp/wt")) out = json.loads(raw) assert out["schema_version"] == "0" - assert list(out["deployments"].keys()) == ["db", "api", "web"] # topo order - assert out["deployments"]["api"]["deps"] == ["db"] - assert out["deployments"]["web"]["deps"] == ["api"] - assert out["deployments"]["api"]["env"]["DATABASE_URL"] == "postgres://db:5432/app" - assert out["deployments"]["web"]["env"]["API_URL"] == "http://api:8000" - # Step-chain `from_=` lowered through the existing v0 IR machinery - api_from = out["deployments"]["api"]["from"] - assert api_from["type"] == "step_chain" - assert api_from["pipeline_v0"]["version"] == "0" - assert any(s.get("cmd", "").startswith("docker build") - for s in api_from["pipeline_v0"]["steps"]) + assert list(out["deployments"].keys()) == ["hello", "greeter"] # topo order + assert out["deployments"]["greeter"]["deps"] == ["hello"] + assert out["deployments"]["hello"]["image"] == "python:3.12-alpine" + assert out["deployments"]["hello"]["cmd"] == [ + "python", "-m", "http.server", "5678", + ] + assert out["deployments"]["greeter"]["env"] == {"HELLO_HOST": "hello"} + # No Step-chain in the new example (from_= is stubbed in v1 cli); + # both entries have from=None. + assert out["deployments"]["hello"]["from"] is None + assert out["deployments"]["greeter"]["from"] is None ``` - [ ] **Step 2: Run only this test** diff --git a/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md b/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md index f665206..8bffa2d 100644 --- a/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md +++ b/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md @@ -61,30 +61,21 @@ hm.dev.dump_registry_json() # -> str (driver-filtered fo ```python import harmont as hm -@hm.target() -def api_image() -> hm.Step: - return hm.sh("docker build -t myapi .", image="docker:24") - -@hm.deploy("db") -def db() -> hm.Deployment: +@hm.deploy("hello") +def hello() -> hm.Deployment: return hm.dev.deploy( - image="postgres:16", - cmd=["postgres", "-c", "shared_buffers=128MB"], - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, ) -@hm.deploy("api") -def api( - db: hm.Dep[hm.Deployment], - api_image: hm.Target[hm.Step], -) -> hm.Deployment: +@hm.deploy("greeter") +def greeter(hello: hm.Dep[hm.Deployment]) -> hm.Deployment: return hm.dev.deploy( - from_=api_image, - port_mapping={8000: hm.dev.port()}, - env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, - volumes={".": "/workspace"}, - workdir="/workspace", + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, + env={"HELLO_HOST": hello.name}, ) ``` @@ -576,15 +567,18 @@ exit 4 # In a temp dir mkdir -p .harmont && cat > .harmont/pipelines.py <<'EOF' import harmont as hm -@hm.deploy("db") -def db(): - return hm.dev.deploy(image="postgres:16", - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}) + +@hm.deploy("hello") +def hello(): + return hm.dev.deploy( + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, + ) EOF -hm dev up db & -sleep 5 -PGPASSWORD=dev psql -h localhost -p $(hm dev port-of db 5432) -U postgres -c 'select 1' +hm dev up hello & +sleep 2 +curl -fsS "http://localhost:$(hm dev port-of hello 5678)" | grep -q "Directory listing" kill %1; wait hm dev ls # should show nothing running ``` diff --git a/tests/dev/test_canonical_example.py b/tests/dev/test_canonical_example.py index 7ec8784..f94ba04 100644 --- a/tests/dev/test_canonical_example.py +++ b/tests/dev/test_canonical_example.py @@ -1,4 +1,9 @@ -"""End-to-end test mirroring the spec's canonical db+api+web example.""" +"""End-to-end test mirroring the spec's canonical hello+greeter example. + +The deployments both use Python's stdlib `http.server` (no third-party +image dependency), which is the smallest practical "native language +facility" demonstration of an HTTP server in a harmont deployment. +""" from __future__ import annotations import json @@ -10,52 +15,33 @@ from pathlib import Path -def test_canonical_db_api_web_dumps_expected_shape(tmp_path: Path): - @hm.target() - def api_image() -> hm.Step: - return hm.sh("docker build -t myapi .", image="docker:24") - - @hm.deploy("db") - def db() -> hm.Deployment: - return hm.dev.deploy( - image="postgres:16", - cmd=["postgres", "-c", "shared_buffers=128MB"], - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, - ) - - @hm.deploy("api") - def api( - db: hm.Dep[hm.Deployment], - api_image: hm.Target[hm.Step], - ) -> hm.Deployment: +def test_canonical_hello_greeter_dumps_expected_shape(tmp_path: Path) -> None: + @hm.deploy("hello") + def hello() -> hm.Deployment: return hm.dev.deploy( - from_=api_image, - port_mapping={8000: hm.dev.port()}, - env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, - volumes={".": "/workspace"}, - workdir="/workspace", + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, ) - @hm.deploy("web") - def web(api: hm.Dep[hm.Deployment]) -> hm.Deployment: + @hm.deploy("greeter") + def greeter(hello: hm.Dep[hm.Deployment]) -> hm.Deployment: return hm.dev.deploy( - image="node:20", - port_mapping={3000: hm.dev.port()}, - env={"API_URL": f"http://{api.name}:8000"}, + image="python:3.12-alpine", + cmd=["python", "-m", "http.server", "5678"], + port_mapping={5678: hm.dev.port()}, + env={"HELLO_HOST": hello.name}, ) raw = hm.dev.dump_registry_json(worktree_root=tmp_path) out = json.loads(raw) assert out["schema_version"] == "0" - assert list(out["deployments"].keys()) == ["db", "api", "web"] # topo order - assert out["deployments"]["api"]["deps"] == ["db"] - assert out["deployments"]["web"]["deps"] == ["api"] - assert out["deployments"]["api"]["env"]["DATABASE_URL"] == "postgres://db:5432/app" - assert out["deployments"]["web"]["env"]["API_URL"] == "http://api:8000" - # Step-chain `from_=` lowered through the existing v0 IR machinery - api_from = out["deployments"]["api"]["from"] - assert api_from["type"] == "step_chain" - assert api_from["pipeline_v0"]["version"] == "0" - assert any(s.get("cmd", "").startswith("docker build") - for s in api_from["pipeline_v0"]["steps"]) + assert list(out["deployments"].keys()) == ["hello", "greeter"] + assert out["deployments"]["greeter"]["deps"] == ["hello"] + assert out["deployments"]["hello"]["image"] == "python:3.12-alpine" + assert out["deployments"]["hello"]["cmd"] == [ + "python", "-m", "http.server", "5678", + ] + assert out["deployments"]["greeter"]["env"] == {"HELLO_HOST": "hello"} + assert out["deployments"]["hello"]["from"] is None + assert out["deployments"]["greeter"]["from"] is None From 158d7136594bb34c0d8a21fbaf061d28dcb25a5b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 22 May 2026 03:06:08 +0000 Subject: [PATCH 26/28] ci: add pytest + ruff + mypy workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR + push-to-main gate. Matrix over python 3.11 / 3.12 (match the package's requires-python = ">=3.11"). Existing release.yml (tag-driven PyPI publish) is untouched. Excludes tests/test_gradle.py and tests/test_haskell.py via --deselect — those have pre-existing failures unrelated to the hm.deploy work and would block PRs unrelated to them. Pre-flight (Step 3): ruff was 49 errors red across harmont/ and tests/dev/ — all pre-existing. Fixed them before adding the workflow so it doesn't land already-broken: moved type-only imports to TYPE_CHECKING blocks (TC001/TC003), ValueError→TypeError for pure type checks (TRY004), noqa suppressions where rename would be lossy (S108 deliberate /tmp sentinel, SLF001 test-isolation internals), split compound assertions (PT018), re-ordered imports (E402), used sys.stderr.write/sys.stdout.write instead of print (T201), extracted f-string exception messages (EM102/TRY003). Tests updated to match TypeError raises. mypy: clean. pytest: 373 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++ harmont/_deploy.py | 4 +-- harmont/dev/__main__.py | 10 ++++---- harmont/dev/_deployment.py | 8 +++--- harmont/dev/_factory.py | 19 ++++++++------ harmont/dev/_registry_dump.py | 5 ++-- tests/dev/test_dep_marker.py | 4 +-- tests/dev/test_deploy_factory.py | 10 +++++--- tests/dev/test_dump_cli.py | 5 +++- tests/dev/test_local_deployment.py | 14 +++++------ tests/dev/test_registry_dump.py | 12 ++++----- tests/examples_render_conftest.py | 16 +++++++----- tests/test_examples_render.py | 5 +++- tests/test_zig_toolchain.py | 1 - 14 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b2b777c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: pytest + ruff + mypy + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install harmont + dev extras + run: pip install -e '.[dev]' + + - name: ruff check + run: ruff check . + + - name: mypy + run: mypy harmont + + - name: pytest + run: | + pytest -v \ + --deselect tests/test_gradle.py \ + --deselect tests/test_haskell.py diff --git a/harmont/_deploy.py b/harmont/_deploy.py index fb3913e..acac143 100644 --- a/harmont/_deploy.py +++ b/harmont/_deploy.py @@ -54,7 +54,7 @@ def deploy( slug: str | None = None, *, name: str | None = None, -) -> "Callable[[Callable[..., Any]], Callable[[], Deployment]]": +) -> Callable[[Callable[..., Any]], Callable[[], Deployment]]: """Register a function as a deployment. The wrapped function returns a :class:`Deployment` (typically the @@ -90,7 +90,7 @@ def api(db: hm.Dep[hm.Deployment]): """ del name # reserved-for-future-use; explicitly drop the unused binding - def decorator(fn: "Callable[..., Any]") -> "Callable[[], Deployment]": + def decorator(fn: Callable[..., Any]) -> Callable[[], Deployment]: validate_target_signature(fn) resolved_slug = slug if slug is not None else fn.__name__ _validate_slug(resolved_slug) diff --git a/harmont/dev/__main__.py b/harmont/dev/__main__.py index c4fea11..9366e61 100644 --- a/harmont/dev/__main__.py +++ b/harmont/dev/__main__.py @@ -22,7 +22,8 @@ def _import_path(path: Path) -> None: location=str(path), ) if spec is None or spec.loader is None: - raise RuntimeError(f"cannot load module from {path}") + msg = f"cannot load module from {path}" + raise RuntimeError(msg) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -30,10 +31,9 @@ def _import_path(path: Path) -> None: def _walk_harmont_dir(root: Path) -> None: harmont_dir = root / ".harmont" if not harmont_dir.is_dir(): - print( + sys.stderr.write( f"hm: no .harmont/ directory in {root}\n" - " → create .harmont/ and add @hm.deploy-decorated functions", - file=sys.stderr, + " → create .harmont/ and add @hm.deploy-decorated functions\n" ) sys.exit(1) for py in sorted(harmont_dir.glob("*.py")): @@ -63,7 +63,7 @@ def main(argv: list[str] | None = None) -> int: root = args.worktree_root if args.worktree_root is not None else Path.cwd() _walk_harmont_dir(root) - print(dump_registry_json(worktree_root=root)) + sys.stdout.write(dump_registry_json(worktree_root=root) + "\n") return 0 diff --git a/harmont/dev/_deployment.py b/harmont/dev/_deployment.py index 19deaac..561a1cc 100644 --- a/harmont/dev/_deployment.py +++ b/harmont/dev/_deployment.py @@ -30,11 +30,11 @@ class LocalDeployment(Deployment): paths (with optional ``:ro`` suffix). """ image: str | None - from_step: "Step | None" + from_step: Step | None cmd: tuple[str, ...] | None - port_mapping: "Mapping[int, _PortSentinel]" - env: "Mapping[str, str]" - volumes: "Mapping[str, str]" + port_mapping: Mapping[int, _PortSentinel] + env: Mapping[str, str] + volumes: Mapping[str, str] workdir: str | None def __post_init__(self) -> None: diff --git a/harmont/dev/_factory.py b/harmont/dev/_factory.py index 525cb18..8d2e9f8 100644 --- a/harmont/dev/_factory.py +++ b/harmont/dev/_factory.py @@ -7,13 +7,16 @@ """ from __future__ import annotations -from collections.abc import Iterable, Mapping - -from harmont._step import Step +from typing import TYPE_CHECKING from ._deployment import LocalDeployment from ._port import _PortSentinel +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from harmont._step import Step + def deploy( *, @@ -36,7 +39,7 @@ def deploy( msg = ( "hm.dev.deploy requires exactly one of `image=` or `from_=`, " f"got image={image!r}, from_={from_!r}\n" - " → pick one. Use `image=\"...\"` for a published image, " + ' → pick one. Use `image="..."` for a published image, ' "`from_=` to build from a Step chain." ) raise ValueError(msg) @@ -80,7 +83,7 @@ def _validate_port_mapping( f"got {type(v).__name__}\n" " → use hm.dev.port() to ask the OS for a free host port" ) - raise ValueError(msg) + raise TypeError(msg) result[k] = v return result @@ -91,14 +94,14 @@ def _validate_env(env: Mapping[str, str] | None) -> Mapping[str, str]: for k, v in env.items(): if not isinstance(k, str): msg = f"hm.dev.deploy env key must be str, got {type(k).__name__}" - raise ValueError(msg) + raise TypeError(msg) if not isinstance(v, str): msg = ( f"hm.dev.deploy env value for {k!r} must be str, " f"got {type(v).__name__}\n" " → call str(...) at the call site so the conversion is explicit" ) - raise ValueError(msg) + raise TypeError(msg) return dict(env) @@ -133,7 +136,7 @@ def _validate_cmd(cmd: Iterable[str] | None) -> tuple[str, ...] | None: f"hm.dev.deploy cmd elements must be str, got {type(x).__name__}\n" " → call str(...) at the call site so the conversion is explicit" ) - raise ValueError(msg) + raise TypeError(msg) return items diff --git a/harmont/dev/_registry_dump.py b/harmont/dev/_registry_dump.py index ffca9c3..8358c47 100644 --- a/harmont/dev/_registry_dump.py +++ b/harmont/dev/_registry_dump.py @@ -25,7 +25,6 @@ from ._deployment import LocalDeployment from ._port import _PortSentinel - _SENTINEL_WIRE = "__hm_dev_port__" @@ -60,7 +59,7 @@ def _lower_from_step(step: Any) -> dict[str, Any]: pipeline_org="hm-dev", pipeline_slug="hm-dev-build", now=0, - base_path=Path("/tmp"), + base_path=Path("/tmp"), # noqa: S108 env={}, ) return {"type": "step_chain", "pipeline_v0": ir} @@ -68,7 +67,7 @@ def _lower_from_step(step: Any) -> dict[str, Any]: def dump_registry_json( *, - worktree_root: "Path | None" = None, + worktree_root: Path | None = None, ) -> str: """Emit the v0 deployment-registry JSON. diff --git a/tests/dev/test_dep_marker.py b/tests/dev/test_dep_marker.py index 985b1ec..21004ec 100644 --- a/tests/dev/test_dep_marker.py +++ b/tests/dev/test_dep_marker.py @@ -14,14 +14,14 @@ def test_dep_marker_alias_subscripts_to_annotated(): # both static and runtime levels. from typing import get_args, get_origin - T = Dep[Deployment] + T = Dep[Deployment] # noqa: N806 assert get_origin(T) is not None args = get_args(T) assert args[0] is Deployment assert isinstance(args[1], _DepMarker) -def test_call_with_deps_resolves_dep_param_from_DEPLOYMENTS(): +def test_call_with_deps_resolves_dep_param_from_DEPLOYMENTS(): # noqa: N802 # Register a fake deployment under the name "db". DEPLOYMENTS["db"] = lambda: Deployment(name="db", driver="local") diff --git a/tests/dev/test_deploy_factory.py b/tests/dev/test_deploy_factory.py index 3f93cbc..3bc9905 100644 --- a/tests/dev/test_deploy_factory.py +++ b/tests/dev/test_deploy_factory.py @@ -44,22 +44,24 @@ def test_port_mapping_keys_must_be_valid_container_ports(): def test_port_mapping_values_must_be_hm_dev_port(): - with pytest.raises(ValueError, match="port_mapping value must be hm.dev.port"): + with pytest.raises(TypeError, match=r"port_mapping value must be hm\.dev\.port"): deploy(image="x", port_mapping={5432: 31337}) # type: ignore[dict-item] def test_env_values_must_be_strings(): - with pytest.raises(ValueError, match="env value for 'PORT' must be str"): + with pytest.raises(TypeError, match="env value for 'PORT' must be str"): deploy(image="x", port_mapping={5432: port()}, env={"PORT": 31337}) # type: ignore[dict-item] def test_cmd_coerces_to_tuple_of_strings(): - d = deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", "-c", "shared_buffers=128MB"]) + d = deploy( + image="x", port_mapping={5432: port()}, cmd=["postgres", "-c", "shared_buffers=128MB"] + ) assert d.cmd == ("postgres", "-c", "shared_buffers=128MB") def test_cmd_rejects_non_string_elements(): - with pytest.raises(ValueError, match="cmd elements must be str"): + with pytest.raises(TypeError, match="cmd elements must be str"): deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", 5432]) # type: ignore[list-item] diff --git a/tests/dev/test_dump_cli.py b/tests/dev/test_dump_cli.py index 2b3fd17..f09386e 100644 --- a/tests/dev/test_dump_cli.py +++ b/tests/dev/test_dump_cli.py @@ -5,7 +5,10 @@ import subprocess import sys import textwrap -from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path def test_dump_cli_walks_harmont_dir_and_prints_registry(tmp_path: Path): diff --git a/tests/dev/test_local_deployment.py b/tests/dev/test_local_deployment.py index 4e965a8..0c24ef0 100644 --- a/tests/dev/test_local_deployment.py +++ b/tests/dev/test_local_deployment.py @@ -1,9 +1,15 @@ """Abstract Deployment + LocalDeployment construction tests.""" from __future__ import annotations +from collections.abc import Mapping + import pytest from harmont._deploy import Deployment +from harmont._step import scratch +from harmont.dev import port +from harmont.dev._deployment import LocalDeployment +from harmont.dev._port import _PortSentinel def test_deployment_is_abstract_dataclass(): @@ -11,19 +17,13 @@ def test_deployment_is_abstract_dataclass(): d = Deployment(name="db", driver="local") assert d.name == "db" assert d.driver == "local" - with pytest.raises(Exception): + with pytest.raises(AttributeError): d.name = "other" # type: ignore[misc] # frozen # --------------------------------------------------------------------------- # Task 3: LocalDeployment tests # --------------------------------------------------------------------------- -from collections.abc import Mapping - -from harmont._step import scratch -from harmont.dev import port -from harmont.dev._deployment import LocalDeployment -from harmont.dev._port import _PortSentinel def test_local_deployment_is_a_deployment_with_driver_local(): diff --git a/tests/dev/test_registry_dump.py b/tests/dev/test_registry_dump.py index ce1d79b..9aaa4af 100644 --- a/tests/dev/test_registry_dump.py +++ b/tests/dev/test_registry_dump.py @@ -18,9 +18,9 @@ def db(): env={"POSTGRES_PASSWORD": "dev"}, ) - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) # noqa: S108 assert out["schema_version"] == "0" - assert out["worktree"] == "/tmp/wt" + assert out["worktree"] == "/tmp/wt" # noqa: S108 assert out["deployments"]["db"] == { "driver": "local", "image": "postgres:16", @@ -45,7 +45,7 @@ def db(): workdir="/workspace", ) - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) # noqa: S108 e = out["deployments"]["db"] assert e["cmd"] == ["postgres", "-c", "shared_buffers=128MB"] assert e["workdir"] == "/workspace" @@ -64,7 +64,7 @@ def api(db: hm.Dep[hm.Deployment]): env={"DB_HOST": db.name}, ) - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) # noqa: S108 assert out["deployments"]["api"]["deps"] == ["db"] assert out["deployments"]["api"]["env"] == {"DB_HOST": "db"} @@ -77,7 +77,7 @@ def api(): port_mapping={8000: hm.dev.port()}, ) - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) # noqa: S108 f = out["deployments"]["api"]["from"] assert f["type"] == "step_chain" assert f["pipeline_v0"]["version"] == "0" @@ -89,5 +89,5 @@ def test_dump_non_local_driver_is_marked_unhandled(): def prod_api(): return Deployment(name="", driver="aws") - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) + out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) # noqa: S108 assert out["deployments"]["prod-api"] == {"driver": "aws", "_unhandled": True} diff --git a/tests/examples_render_conftest.py b/tests/examples_render_conftest.py index fb01404..23b9978 100644 --- a/tests/examples_render_conftest.py +++ b/tests/examples_render_conftest.py @@ -11,7 +11,10 @@ import pathlib import sys from contextlib import contextmanager -from typing import Iterator +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator def harmont_cli_examples_root() -> pathlib.Path | None: @@ -32,8 +35,8 @@ def isolated_registry() -> Iterator[None]: from harmont import _deps, _registry, _target saved_regs = list(_registry.REGISTRATIONS) - saved_targets_by_name = dict(_deps._TARGETS_BY_NAME) - saved_target_cache = dict(_target._TARGET_CACHE) + saved_targets_by_name = dict(_deps._TARGETS_BY_NAME) # noqa: SLF001 + saved_target_cache = dict(_target._TARGET_CACHE) # noqa: SLF001 _registry.clear_registry() _deps.clear_target_names() @@ -45,8 +48,8 @@ def isolated_registry() -> Iterator[None]: _deps.clear_target_names() _target.clear_target_cache() _registry.REGISTRATIONS.extend(saved_regs) - _deps._TARGETS_BY_NAME.update(saved_targets_by_name) - _target._TARGET_CACHE.update(saved_target_cache) + _deps._TARGETS_BY_NAME.update(saved_targets_by_name) # noqa: SLF001 + _target._TARGET_CACHE.update(saved_target_cache) # noqa: SLF001 def load_pipeline_module(example_dir: pathlib.Path) -> None: @@ -58,7 +61,8 @@ def load_pipeline_module(example_dir: pathlib.Path) -> None: spec = importlib.util.spec_from_file_location( f"_harmont_example_{example_dir.name}", pipeline_py ) - assert spec is not None and spec.loader is not None + assert spec is not None + assert spec.loader is not None mod = importlib.util.module_from_spec(spec) sys.modules[spec.name] = mod try: diff --git a/tests/test_examples_render.py b/tests/test_examples_render.py index 8aa00c7..9270c5f 100644 --- a/tests/test_examples_render.py +++ b/tests/test_examples_render.py @@ -6,10 +6,13 @@ from __future__ import annotations import json -import pathlib +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + import pathlib + from .examples_render_conftest import ( harmont_cli_examples_root, isolated_registry, diff --git a/tests/test_zig_toolchain.py b/tests/test_zig_toolchain.py index 542f0ad..0b86937 100644 --- a/tests/test_zig_toolchain.py +++ b/tests/test_zig_toolchain.py @@ -41,7 +41,6 @@ def test_pipeline_with_shared_toolchain_emits_one_install() -> None: ZigToolchain must emit exactly one :zig: install node in the IR.""" import harmont._registry as reg import harmont._target as targets - import harmont._deps as deps reg.clear_registry() targets.clear_target_cache() From f0ba0fa45e7649bd08fa4cc3a226243dc6dd451b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 22 May 2026 04:25:47 +0000 Subject: [PATCH 27/28] docs: remove all plan files Plans are scratchpads for implementation; once code lands the spec + tests + commit messages carry the load. Remove the plans dir entirely (including the pre-existing pypi-tag-release-cd plan from prior work). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-21-hm-dev-deploy-py.md | 2374 ----------------- .../plans/2026-05-21-pypi-tag-release-cd.md | 470 ---- 2 files changed, 2844 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md delete mode 100644 docs/superpowers/plans/2026-05-21-pypi-tag-release-cd.md diff --git a/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md b/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md deleted file mode 100644 index 126063e..0000000 --- a/docs/superpowers/plans/2026-05-21-hm-dev-deploy-py.md +++ /dev/null @@ -1,2374 +0,0 @@ -# `harmont-py`: hm.deploy + hm.dev DSL — 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:** Ship the Python DSL surface for local deployments: `@hm.deploy` (driver-agnostic decorator), `hm.Dep[T]` (PEP-593 fixture marker), `hm.Deployment` (abstract dataclass), `hm.dev.deploy(...)` (local-driver factory), `hm.dev.port()` (sentinel), and `harmont.dev.dump_registry_json()` + `python -m harmont.dev --dump-registry` CLI shim that emits the v0 JSON the Rust CLI consumes. - -**Architecture:** Top-level `harmont._deploy` houses the abstract `Deployment`, the `@hm.deploy` decorator, the `Dep[T]` marker, and the `DEPLOYMENTS` registry. Driver-specific code lives in `harmont/dev/`: `_deployment.py` (LocalDeployment), `_port.py` (sentinel), `_factory.py` (deploy(...)), `_registry_dump.py` (JSON emitter), and `__main__.py` (CLI shim). The dep-graph resolver extends the existing `harmont._deps.call_with_deps` so `Dep[T]` markers participate in the same fixture-injection pipeline as `Target[T]`. - -**Tech Stack:** Python 3.11+, frozen `dataclasses`, `typing.Annotated` (PEP 593), pytest (incl. `pytest.raises`). No new runtime deps. The cli side is out of scope for this plan. - -**Spec:** `docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md` (committed to this branch). Read § 1 (DSL surface) and § 5 (error handling) before starting — error-message shapes are tested literally. - -**Branch:** `feat/hm-dev-deploy`. Already created off `main`. - -**Commit cadence:** Every task ends with a commit. No exceptions. The commit subject line is in the example commands. - ---- - -## File Map - -### Create (harmont-py) - -- `harmont/_deploy.py` — abstract `Deployment` dataclass; `@hm.deploy` decorator; `Dep[T]` PEP-593 marker; `DEPLOYMENTS` registry; topo-sort + dep-graph resolver. -- `harmont/dev/__init__.py` — re-exports `deploy`, `port`, `LocalDeployment`, `dump_registry_json`. -- `harmont/dev/__main__.py` — `python -m harmont.dev --dump-registry` CLI shim. -- `harmont/dev/_deployment.py` — `LocalDeployment` frozen dataclass + `__post_init__` validation. -- `harmont/dev/_port.py` — `_PortSentinel` singleton + `port()` factory. -- `harmont/dev/_factory.py` — `deploy(...)` factory function (field validation + LocalDeployment construction). -- `harmont/dev/_registry_dump.py` — `dump_registry_json()` walks `DEPLOYMENTS` in topo order, emits the spec's JSON shape. -- `tests/dev/__init__.py` — empty, marks dir as test package. -- `tests/dev/conftest.py` — pytest fixture that clears `DEPLOYMENTS`, `_TARGETS_BY_NAME`, `REGISTRATIONS` between tests. -- `tests/dev/test_port_sentinel.py` — sentinel behavior + misuse. -- `tests/dev/test_local_deployment.py` — `LocalDeployment` field validation. -- `tests/dev/test_deploy_factory.py` — `hm.dev.deploy(...)` XOR rule, port_mapping shape, env/cmd coercion, volumes. -- `tests/dev/test_decorator.py` — slug regex, duplicate-slug, missing marker, dep cycle, `Dep[T]` injection, `Target[T]` injection co-exists. -- `tests/dev/test_registry_dump.py` — golden JSON for canonical db+api+web example; topo ordering; non-local entries marked `_unhandled`. -- `tests/dev/test_dump_cli.py` — `python -m harmont.dev --dump-registry` against a temp `.harmont/`. - -### Modify (harmont-py) - -- `harmont/__init__.py` — re-export `deploy` (the decorator), `Dep`, `Deployment`, and the `dev` submodule. -- `harmont/_deps.py` — extend `call_with_deps` + `validate_target_signature` + `_marker_for` to recognize `Dep[T]` markers and resolve them against `DEPLOYMENTS`. -- `harmont/_typing.py` — add `_DepMarker` sentinel + `Dep` PEP-593 alias. -- `CLAUDE.md` — append a "Deployments (`hm.deploy` + `hm.dev`)" section to the public surface table. - -### Do NOT touch - -- `harmont/_step.py`, `harmont/pipeline.py`, `harmont/keygen.py` — already do exactly what we need (`LocalDeployment.from_step` reuses `Step` as-is; `hm.dev.deploy(from_=Step)` lowers via `pipeline()` + `pipeline_to_json` at registry-dump time). -- Any toolchain (`harmont/haskell.py`, etc.) — unrelated. -- `harmont/_envelope.py` — that's the pipeline envelope; deployments get their own dumper. Look at it as a structural reference but do not modify. - ---- - -## Task 1: Scaffold `harmont/_deploy.py` with abstract `Deployment` - -Sets the cross-driver foundation. Empty subclass scaffolding for `LocalDeployment` (which gets fleshed out in Task 3). - -**Files:** -- Create: `harmont/_deploy.py` -- Test: `tests/dev/test_local_deployment.py` (only the abstract-type test in this task) -- Modify: `tests/dev/__init__.py` (create empty) -- Modify: `tests/dev/conftest.py` (create with reset fixture) - -- [ ] **Step 1: Create `tests/dev/__init__.py`** - -```python -``` - -Yes, empty. The file's existence marks the dir. - -- [ ] **Step 2: Create `tests/dev/conftest.py`** - -```python -"""Per-test reset of every registry the deploy DSL touches.""" -from __future__ import annotations - -import pytest - -from harmont._deploy import DEPLOYMENTS -from harmont._deps import _TARGETS_BY_NAME, _RESOLVING -from harmont._registry import REGISTRATIONS - - -@pytest.fixture(autouse=True) -def _reset_registries(): - """Clear every module-level registry before each test so order is irrelevant.""" - DEPLOYMENTS.clear() - _TARGETS_BY_NAME.clear() - _RESOLVING.clear() - REGISTRATIONS.clear() - yield - DEPLOYMENTS.clear() - _TARGETS_BY_NAME.clear() - _RESOLVING.clear() - REGISTRATIONS.clear() -``` - -- [ ] **Step 3: Write the failing test** - -In `tests/dev/test_local_deployment.py`: - -```python -"""Abstract Deployment + LocalDeployment construction tests.""" -from __future__ import annotations - -import pytest - -from harmont._deploy import Deployment - - -def test_deployment_is_abstract_dataclass(): - """Deployment carries name + driver, is frozen, and is constructible (sentinel-level).""" - d = Deployment(name="db", driver="local") - assert d.name == "db" - assert d.driver == "local" - with pytest.raises(Exception): - d.name = "other" # type: ignore[misc] # frozen -``` - -- [ ] **Step 4: Run test to verify it fails** - -```bash -pytest tests/dev/test_local_deployment.py -v -``` - -Expected: `ImportError: cannot import name 'Deployment' from 'harmont._deploy'`. - -- [ ] **Step 5: Implement `harmont/_deploy.py` (abstract type only)** - -```python -"""Driver-agnostic deployment registry, decorator, and Dep marker. - -This module is intentionally driver-free. Concrete deployment types -(``LocalDeployment``, future ``AwsDeployment``, …) live in their own -driver subpackages (``harmont.dev``, future ``harmont.aws``). -The registry stores deployments polymorphically; CLI subcommands filter -by ``isinstance`` or by the ``driver`` discriminator. -""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable - - -@dataclass(frozen=True) -class Deployment: - """Abstract deployment record. Subclassed per driver. - - ``name`` is the slug the user passed to ``@hm.deploy``. - ``driver`` is the discriminator string ("local" for ``hm.dev``). - """ - name: str - driver: str - - -# Registry: slug -> zero-arg callable that re-invokes the user-defined -# function with deps resolved. Same shape as REGISTRATIONS for pipelines. -DEPLOYMENTS: dict[str, Callable[[], Deployment]] = {} -``` - -- [ ] **Step 6: Run test to verify it passes** - -```bash -pytest tests/dev/test_local_deployment.py -v -``` - -Expected: 1 passed. - -- [ ] **Step 7: Commit** - -```bash -git add harmont/_deploy.py tests/dev/__init__.py tests/dev/conftest.py tests/dev/test_local_deployment.py -git commit -m "$(cat <<'EOF' -feat(deploy): scaffold abstract Deployment dataclass + registry - -Sets the driver-agnostic foundation for hm.deploy. Concrete -LocalDeployment (Task 3) subclasses Deployment; the DEPLOYMENTS -registry stores polymorphic entries. Test-only reset fixture covers -DEPLOYMENTS plus the existing TARGETS/REGISTRATIONS registries so -all three are wiped between tests. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: `hm.dev.port()` sentinel - -**Files:** -- Create: `harmont/dev/__init__.py` -- Create: `harmont/dev/_port.py` -- Test: `tests/dev/test_port_sentinel.py` - -- [ ] **Step 1: Write the failing test** - -In `tests/dev/test_port_sentinel.py`: - -```python -"""hm.dev.port() sentinel: equality, repr, and structural use.""" -from __future__ import annotations - -from harmont.dev import port - - -def test_port_returns_sentinel_singleton(): - a = port() - b = port() - assert a is b # singleton — equality-by-identity is fine - assert a == b - - -def test_port_repr_is_stable_and_introspectable(): - assert repr(port()) == "" - - -def test_port_is_hashable(): - # frozen LocalDeployment uses port_mapping values inside a Mapping; - # being hashable means user code can put it in sets / tuple keys - # without surprise. - {port(): 1} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pytest tests/dev/test_port_sentinel.py -v -``` - -Expected: `ModuleNotFoundError: No module named 'harmont.dev'`. - -- [ ] **Step 3: Implement `harmont/dev/_port.py`** - -```python -"""hm.dev.port() — the OS-assigned-host-port sentinel. - -The sentinel is only meaningful as a value in -``hm.dev.deploy(..., port_mapping={CONTAINER_PORT: hm.dev.port()})``. -Any other position (env value, cmd arg, …) is rejected at the call -site that consumes it, with a fix-directed message per PRINCIPLES § 5. -""" -from __future__ import annotations - - -class _PortSentinel: - __slots__ = () - - def __repr__(self) -> str: - return "" - - def __eq__(self, other: object) -> bool: - return isinstance(other, _PortSentinel) - - def __hash__(self) -> int: - return hash(_PortSentinel) - - -_SINGLETON = _PortSentinel() - - -def port() -> _PortSentinel: - """Return the sentinel for an OS-assigned host port. - - Use only as a ``port_mapping`` value: - - hm.dev.deploy( - image="postgres:16", - port_mapping={5432: hm.dev.port()}, - ) - """ - return _SINGLETON -``` - -- [ ] **Step 4: Implement `harmont/dev/__init__.py` (minimal, re-export only what's built)** - -```python -"""harmont.dev — local Docker deployment driver. - -Public surface (grows across tasks): - - deploy(*, image=None, from_=None, cmd=None, - port_mapping=None, env=None, - volumes=None, workdir=None) -> LocalDeployment - port() -> _PortSentinel - LocalDeployment (concrete subclass) - dump_registry_json() -> str -""" -from __future__ import annotations - -from ._port import _PortSentinel, port - -__all__ = ["_PortSentinel", "port"] -``` - -- [ ] **Step 5: Run test to verify it passes** - -```bash -pytest tests/dev/test_port_sentinel.py -v -``` - -Expected: 3 passed. - -- [ ] **Step 6: Commit** - -```bash -git add harmont/dev/__init__.py harmont/dev/_port.py tests/dev/test_port_sentinel.py -git commit -m "$(cat <<'EOF' -feat(dev): add hm.dev.port() sentinel for OS-assigned host ports - -Singleton with stable repr and hash. Misuse outside port_mapping -is detected by deploy()'s field validation (Task 4), not at the -port() call site, so the error points at the exact misuse location. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: `LocalDeployment` frozen dataclass - -**Files:** -- Create: `harmont/dev/_deployment.py` -- Modify: `harmont/dev/__init__.py` -- Test: `tests/dev/test_local_deployment.py` (append) - -- [ ] **Step 1: Write the failing tests (append to `tests/dev/test_local_deployment.py`)** - -```python -from collections.abc import Mapping - -from harmont._deploy import Deployment -from harmont._step import Step, scratch -from harmont.dev import port -from harmont.dev._deployment import LocalDeployment -from harmont.dev._port import _PortSentinel - - -def test_local_deployment_is_a_deployment_with_driver_local(): - d = LocalDeployment( - name="db", - driver="local", - image="postgres:16", - from_step=None, - cmd=None, - port_mapping={5432: port()}, - env={}, - volumes={}, - workdir=None, - ) - assert isinstance(d, Deployment) - assert d.driver == "local" - assert d.image == "postgres:16" - - -def test_local_deployment_rejects_non_local_driver(): - import pytest - with pytest.raises(ValueError, match="driver must be 'local'"): - LocalDeployment( - name="db", driver="aws", - image="postgres:16", from_step=None, cmd=None, - port_mapping={5432: port()}, - env={}, volumes={}, workdir=None, - ) - - -def test_local_deployment_holds_step_chain(): - s = scratch().sh("echo hi", image="alpine:3.20") - d = LocalDeployment( - name="api", driver="local", - image=None, from_step=s, cmd=None, - port_mapping={8000: port()}, - env={}, volumes={}, workdir=None, - ) - assert d.from_step is s - assert d.image is None - - -def test_port_mapping_is_a_mapping_of_int_to_port_sentinel(): - d = LocalDeployment( - name="db", driver="local", - image="postgres:16", from_step=None, cmd=None, - port_mapping={5432: port()}, - env={}, volumes={}, workdir=None, - ) - assert isinstance(d.port_mapping, Mapping) - [(cport, sentinel)] = d.port_mapping.items() - assert cport == 5432 - assert isinstance(sentinel, _PortSentinel) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_local_deployment.py -v -``` - -Expected: ImportError or ModuleNotFoundError on `harmont.dev._deployment`. - -- [ ] **Step 3: Implement `harmont/dev/_deployment.py`** - -```python -"""LocalDeployment — the concrete dataclass for the local Docker driver. - -Construction is mediated by ``harmont.dev._factory.deploy(...)``; the -factory does input validation and coerces fields. ``__post_init__`` is -the last-line invariant check (driver must be 'local'). -""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from harmont._deploy import Deployment - -if TYPE_CHECKING: - from collections.abc import Mapping - - from harmont._step import Step - - from ._port import _PortSentinel - - -@dataclass(frozen=True) -class LocalDeployment(Deployment): - """Local Docker deployment record. - - Exactly one of ``image`` or ``from_step`` is non-None — enforced by - ``deploy(...)``. ``port_mapping`` keys are container ports (1..65535); - values are ``_PortSentinel`` (the ``hm.dev.port()`` singleton). - ``volumes`` maps host paths (relative or absolute) to container - paths (with optional ``:ro`` suffix). - """ - image: str | None - from_step: "Step | None" - cmd: tuple[str, ...] | None - port_mapping: "Mapping[int, _PortSentinel]" - env: "Mapping[str, str]" - volumes: "Mapping[str, str]" - workdir: str | None - - def __post_init__(self) -> None: - if self.driver != "local": - msg = ( - f"LocalDeployment.driver must be 'local', got {self.driver!r}\n" - " → use the harmont.dev._factory.deploy() function " - "instead of constructing LocalDeployment directly" - ) - raise ValueError(msg) -``` - -- [ ] **Step 4: Re-export from `harmont/dev/__init__.py`** - -Update `harmont/dev/__init__.py` so its content becomes: - -```python -"""harmont.dev — local Docker deployment driver. - -Public surface (grows across tasks): - - deploy(*, image=None, from_=None, cmd=None, - port_mapping=None, env=None, - volumes=None, workdir=None) -> LocalDeployment - port() -> _PortSentinel - LocalDeployment (concrete subclass) - dump_registry_json() -> str -""" -from __future__ import annotations - -from ._deployment import LocalDeployment -from ._port import _PortSentinel, port - -__all__ = ["LocalDeployment", "_PortSentinel", "port"] -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -pytest tests/dev/test_local_deployment.py -v -``` - -Expected: 4 passed. - -- [ ] **Step 6: Commit** - -```bash -git add harmont/dev/_deployment.py harmont/dev/__init__.py tests/dev/test_local_deployment.py -git commit -m "$(cat <<'EOF' -feat(dev): add LocalDeployment frozen dataclass - -Concrete subclass of Deployment for the local Docker driver. -__post_init__ enforces driver=='local'; everything else is a -plain dataclass field. The deploy(...) factory in Task 4 is the -sanctioned constructor. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: `hm.dev.deploy(...)` factory + field validation - -**Files:** -- Create: `harmont/dev/_factory.py` -- Modify: `harmont/dev/__init__.py` -- Test: `tests/dev/test_deploy_factory.py` - -- [ ] **Step 1: Write the failing tests** - -In `tests/dev/test_deploy_factory.py`: - -```python -"""hm.dev.deploy(...) field validation + LocalDeployment construction.""" -from __future__ import annotations - -import pytest - -from harmont._step import Step, scratch -from harmont.dev import LocalDeployment, deploy, port - - -def test_deploy_with_raw_image_returns_local_deployment(): - d = deploy( - image="postgres:16", - port_mapping={5432: port()}, - env={"POSTGRES_PASSWORD": "dev"}, - ) - assert isinstance(d, LocalDeployment) - assert d.image == "postgres:16" - assert d.from_step is None - # name is set later by the @hm.deploy decorator; factory leaves it "" - assert d.name == "" - - -def test_deploy_with_from_step(): - s = scratch().sh("echo build", image="alpine:3.20") - d = deploy(from_=s, port_mapping={8000: port()}) - assert d.image is None - assert d.from_step is s - - -def test_deploy_requires_exactly_one_of_image_or_from(): - with pytest.raises(ValueError, match="exactly one of `image=` or `from_=`"): - deploy(port_mapping={5432: port()}) - with pytest.raises(ValueError, match="exactly one of `image=` or `from_=`"): - deploy(image="x", from_=scratch().sh("echo"), port_mapping={5432: port()}) - - -def test_port_mapping_keys_must_be_valid_container_ports(): - with pytest.raises(ValueError, match="port_mapping key must be int in"): - deploy(image="x", port_mapping={0: port()}) - with pytest.raises(ValueError, match="port_mapping key must be int in"): - deploy(image="x", port_mapping={70000: port()}) - with pytest.raises(ValueError, match="port_mapping key must be int in"): - deploy(image="x", port_mapping={"5432": port()}) # type: ignore[dict-item] - - -def test_port_mapping_values_must_be_hm_dev_port(): - with pytest.raises(ValueError, match="port_mapping value must be hm.dev.port"): - deploy(image="x", port_mapping={5432: 31337}) # type: ignore[dict-item] - - -def test_env_values_must_be_strings(): - with pytest.raises(ValueError, match="env value for 'PORT' must be str"): - deploy(image="x", port_mapping={5432: port()}, env={"PORT": 31337}) # type: ignore[dict-item] - - -def test_cmd_coerces_to_tuple_of_strings(): - d = deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", "-c", "shared_buffers=128MB"]) - assert d.cmd == ("postgres", "-c", "shared_buffers=128MB") - - -def test_cmd_rejects_non_string_elements(): - with pytest.raises(ValueError, match="cmd elements must be str"): - deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", 5432]) # type: ignore[list-item] - - -def test_volumes_keys_resolve_relative_to_worktree_at_dump_time(): - # The factory keeps host paths verbatim; resolution happens in - # _registry_dump.py. Here we only check that the dict is preserved. - d = deploy(image="x", port_mapping={5432: port()}, volumes={".": "/workspace"}) - assert dict(d.volumes) == {".": "/workspace"} - - -def test_workdir_must_be_absolute(): - with pytest.raises(ValueError, match="workdir must be an absolute path"): - deploy(image="x", port_mapping={5432: port()}, workdir="workspace") -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_deploy_factory.py -v -``` - -Expected: ImportError on `deploy` from `harmont.dev`. - -- [ ] **Step 3: Implement `harmont/dev/_factory.py`** - -```python -"""hm.dev.deploy(...) — the public factory for LocalDeployment. - -Validation is deliberately strict and fix-directed. The @hm.deploy -decorator only learns the slug at decoration time, so this factory -emits LocalDeployment with name="" — the decorator stamps the slug -in afterwards via dataclasses.replace. -""" -from __future__ import annotations - -from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any - -from harmont._step import Step - -from ._deployment import LocalDeployment -from ._port import _PortSentinel - -if TYPE_CHECKING: - pass - - -def deploy( - *, - image: str | None = None, - from_: "Step | None" = None, - cmd: "Iterable[str] | None" = None, - port_mapping: "Mapping[int, _PortSentinel] | None" = None, - env: "Mapping[str, str] | None" = None, - volumes: "Mapping[str, str] | None" = None, - workdir: str | None = None, -) -> LocalDeployment: - """Construct a LocalDeployment. - - Exactly one of ``image`` or ``from_`` is required. ``port_mapping`` - keys are container ports (1..65535); values must be the - ``hm.dev.port()`` sentinel in v1. See the design spec § 1 for the - full validation table. - """ - if (image is None) == (from_ is None): - msg = ( - "hm.dev.deploy requires exactly one of `image=` or `from_=`, " - f"got image={image!r}, from_={from_!r}\n" - " → pick one. Use `image=\"...\"` for a published image, " - "`from_=` to build from a Step chain." - ) - raise ValueError(msg) - if from_ is not None and not isinstance(from_, Step): - msg = ( - f"hm.dev.deploy from_= must be a hm.Step, got {type(from_).__name__}\n" - " → pass a Step chain (e.g. hm.sh(...) or a @hm.target() value)" - ) - raise ValueError(msg) - - pm = _validate_port_mapping(port_mapping) - env_resolved = _validate_env(env) - volumes_resolved = _validate_volumes(volumes) - cmd_resolved = _validate_cmd(cmd) - workdir_resolved = _validate_workdir(workdir) - - return LocalDeployment( - name="", # decorator stamps the slug in - driver="local", - image=image, - from_step=from_, - cmd=cmd_resolved, - port_mapping=pm, - env=env_resolved, - volumes=volumes_resolved, - workdir=workdir_resolved, - ) - - -def _validate_port_mapping( - pm: "Mapping[int, _PortSentinel] | None", -) -> Mapping[int, _PortSentinel]: - if pm is None: - return {} - result: dict[int, _PortSentinel] = {} - for k, v in pm.items(): - if not isinstance(k, int) or k < 1 or k > 65535: - msg = ( - f"hm.dev.deploy port_mapping key must be int in 1..65535, " - f"got {k!r}\n" - " → keys are container ports the service listens on" - ) - raise ValueError(msg) - if not isinstance(v, _PortSentinel): - msg = ( - f"hm.dev.deploy port_mapping value must be hm.dev.port(), " - f"got {type(v).__name__}\n" - " → use hm.dev.port() to ask the OS for a free host port" - ) - raise ValueError(msg) - result[k] = v - return result - - -def _validate_env(env: "Mapping[str, str] | None") -> Mapping[str, str]: - if env is None: - return {} - for k, v in env.items(): - if not isinstance(k, str): - msg = f"hm.dev.deploy env key must be str, got {type(k).__name__}" - raise ValueError(msg) - if not isinstance(v, str): - msg = ( - f"hm.dev.deploy env value for {k!r} must be str, " - f"got {type(v).__name__}\n" - " → call str(...) at the call site so the conversion is explicit" - ) - raise ValueError(msg) - return dict(env) - - -def _validate_volumes( - volumes: "Mapping[str, str] | None", -) -> Mapping[str, str]: - if volumes is None: - return {} - for hp, cp in volumes.items(): - if not isinstance(hp, str) or not hp: - msg = ( - f"hm.dev.deploy volumes host path must be a non-empty str, " - f"got {hp!r}" - ) - raise ValueError(msg) - if not isinstance(cp, str) or not cp.startswith("/"): - msg = ( - f"hm.dev.deploy volumes container path {cp!r} must start with " - "'/'; append ':ro' for read-only mounts (e.g. '/workspace:ro')" - ) - raise ValueError(msg) - return dict(volumes) - - -def _validate_cmd(cmd: "Iterable[str] | None") -> tuple[str, ...] | None: - if cmd is None: - return None - items = tuple(cmd) - for x in items: - if not isinstance(x, str): - msg = ( - f"hm.dev.deploy cmd elements must be str, got {type(x).__name__}\n" - " → call str(...) at the call site so the conversion is explicit" - ) - raise ValueError(msg) - return items - - -def _validate_workdir(workdir: str | None) -> str | None: - if workdir is None: - return None - if not workdir.startswith("/"): - msg = ( - f"hm.dev.deploy workdir must be an absolute path, got {workdir!r}\n" - " → workdir is interpreted inside the container; " - "use a path that starts with '/'" - ) - raise ValueError(msg) - return workdir -``` - -- [ ] **Step 4: Re-export `deploy` from `harmont/dev/__init__.py`** - -Replace `harmont/dev/__init__.py` content with: - -```python -"""harmont.dev — local Docker deployment driver. - -Public surface (grows across tasks): - - deploy(*, image=None, from_=None, cmd=None, - port_mapping=None, env=None, - volumes=None, workdir=None) -> LocalDeployment - port() -> _PortSentinel - LocalDeployment (concrete subclass) - dump_registry_json() -> str (Task 8) -""" -from __future__ import annotations - -from ._deployment import LocalDeployment -from ._factory import deploy -from ._port import _PortSentinel, port - -__all__ = ["LocalDeployment", "_PortSentinel", "deploy", "port"] -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -pytest tests/dev/test_deploy_factory.py -v -``` - -Expected: 10 passed. - -- [ ] **Step 6: Commit** - -```bash -git add harmont/dev/_factory.py harmont/dev/__init__.py tests/dev/test_deploy_factory.py -git commit -m "$(cat <<'EOF' -feat(dev): hm.dev.deploy(...) factory with field validation - -Strict, fix-directed validation per PRINCIPLES § 5: every error -message points at the misuse and states the fix. The factory leaves -name="" so the @hm.deploy decorator can stamp the slug in via -dataclasses.replace after deciding the slug from its arg or fn name. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: `hm.Dep[T]` marker + extend `call_with_deps` - -The marker lives in `harmont._typing` alongside `Target`; the resolver lives in `harmont._deps` alongside the existing target/baseimage resolution. The dep registry is `harmont._deploy.DEPLOYMENTS` (already created in Task 1). - -**Files:** -- Modify: `harmont/_typing.py` -- Modify: `harmont/_deps.py` -- Test: `tests/dev/test_dep_marker.py` (new) - -- [ ] **Step 1: Write the failing tests** - -In `tests/dev/test_dep_marker.py`: - -```python -"""hm.Dep[T] marker is detected; call_with_deps resolves it from DEPLOYMENTS.""" -from __future__ import annotations - -import pytest - -from harmont import Dep -from harmont._deploy import DEPLOYMENTS, Deployment -from harmont._deps import call_with_deps - - -def test_dep_marker_alias_subscripts_to_annotated(): - # Dep is PEP-593 Annotated[T, _DEP_MARKER]; subscripting works at - # both static and runtime levels. - from typing import get_args, get_origin - - T = Dep[Deployment] - assert get_origin(T) is not None - args = get_args(T) - assert args[0] is Deployment - - -def test_call_with_deps_resolves_dep_param_from_DEPLOYMENTS(): - # Register a fake deployment under the name "db". - DEPLOYMENTS["db"] = lambda: Deployment(name="db", driver="local") - - def consumer(db: Dep[Deployment]) -> Deployment: - return db - - result = call_with_deps(consumer) - assert isinstance(result, Deployment) - assert result.name == "db" - - -def test_call_with_deps_raises_when_dep_unknown(): - def consumer(redis: Dep[Deployment]) -> Deployment: - return redis - - with pytest.raises(ValueError, match="hm.Dep parameter 'redis' refers to"): - call_with_deps(consumer) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_dep_marker.py -v -``` - -Expected: ImportError — `Dep` not in `harmont`. - -- [ ] **Step 3: Add `_DepMarker` + `Dep` alias in `harmont/_typing.py`** - -Append to `harmont/_typing.py`: - -```python -class _DepMarker: - """Sentinel for Annotated metadata. Marks a parameter as a - dependency on another @hm.deploy by parameter name. The injected - value is the resolved Deployment. - """ - - __slots__ = () - - def __repr__(self) -> str: - return "" - - -_DEP_MARKER = _DepMarker() - - -# hm.Dep[Deployment] (or a concrete subclass) -> Annotated[T, _DEP_MARKER]. -Dep = Annotated[T, _DEP_MARKER] -``` - -- [ ] **Step 4: Extend `harmont/_deps.py` to resolve `_DepMarker`** - -The current `_marker_for` returns `_TARGET_MARKER` or `_BaseImageMarker`. Extend it to also return `_DEP_MARKER`. The current `call_with_deps` dispatches on the marker type. Add a `_DEP_MARKER` branch that looks up `harmont._deploy.DEPLOYMENTS`. - -Locate the existing `_marker_for` function in `harmont/_deps.py` and update it to recognize the dep marker. Then extend the resolver loop. Concrete edits (full new file content of `harmont/_deps.py` once edits are applied): - -```python -"""Shared dependency resolution for @hm.target, @hm.pipeline, and @hm.deploy. - -Strict-marker model: -- ``Target[T]`` — resolve by parameter name from the global - target registry; raise if not found. -- ``BaseImage["X"]`` — inject a scratch-rooted ``Step(image=X)``. -- ``Dep[T]`` — resolve by parameter name from - ``harmont._deploy.DEPLOYMENTS``; raise if - not found. -- plain param with default — bind the default value. -- anything else — raise at decoration time via - :func:`validate_target_signature`. - -Cycle detection uses a module-level "currently resolving" stack keyed -by function name; the dump_registry_json render clears it at the -start of every render along with the target memoization cache. -""" - -from __future__ import annotations - -import inspect -import typing -from typing import TYPE_CHECKING, Any - -from ._step import Step -from ._typing import _DEP_MARKER, _TARGET_MARKER, _BaseImageMarker, _DepMarker, _TargetMarker - -if TYPE_CHECKING: - from collections.abc import Callable - - -_TARGETS_BY_NAME: dict[str, Callable[[], Any]] = {} -_RESOLVING: list[str] = [] - - -def register_named_target(name: str, fn: Callable[[], Any]) -> None: - """Register a named target. Raises on duplicate name.""" - if name in _TARGETS_BY_NAME: - msg = ( - f"hm: duplicate target name {name!r}\n" - " → each @hm.target must have a unique name; pass " - 'name="..." to disambiguate' - ) - raise ValueError(msg) - _TARGETS_BY_NAME[name] = fn - - -def clear_target_names() -> None: - """Reset the name registry and cycle-detection stack.""" - _TARGETS_BY_NAME.clear() - _RESOLVING.clear() - - -def _param_kind_error(param: inspect.Parameter) -> str | None: - """Return a fix-directed error message if `param` has a forbidden kind.""" - kind = param.kind - if kind == inspect.Parameter.VAR_POSITIONAL: - return ( - "hm: target functions cannot take *args\n" - " → declare each dependency as an explicit named parameter" - ) - if kind == inspect.Parameter.VAR_KEYWORD: - return ( - "hm: target functions cannot take **kwargs\n" - " → declare each dependency as an explicit named parameter" - ) - if kind == inspect.Parameter.POSITIONAL_ONLY: - return ( - f"hm: target functions cannot have positional-only " - f"parameters (got {param.name!r})\n" - " → remove the '/' marker; parameters must be name-resolvable" - ) - return None - - -def _marker_for(annotation: Any) -> object | None: - """Return the hm-specific marker present on an ``Annotated[T, ...]`` - annotation, else None. Markers: ``_TargetMarker``, ``_BaseImageMarker``, - ``_DepMarker``. - """ - if typing.get_origin(annotation) is None: - return None - metadata = typing.get_args(annotation)[1:] - for m in metadata: - if isinstance(m, (_TargetMarker, _BaseImageMarker, _DepMarker)): - return m - return None - - -def validate_target_signature(fn: Callable[..., Any]) -> None: - """Raise at decoration time if any param lacks a marker or default.""" - sig = inspect.signature(fn) - hints = typing.get_type_hints(fn, include_extras=True) - for name, param in sig.parameters.items(): - kind_err = _param_kind_error(param) - if kind_err is not None: - raise ValueError(kind_err) - if param.default is not inspect.Parameter.empty: - continue - ann = hints.get(name) - if ann is None or _marker_for(ann) is None: - msg = ( - f"hm: parameter {name!r} on {fn.__name__} must carry a marker " - "or have a default.\n" - " → add `hm.Target[T]`, `hm.Dep[T]`, or " - "`Annotated[Step, hm.BaseImage(\"...\")]`, or set a default value." - ) - raise ValueError(msg) - - -def call_with_deps(fn: Callable[..., Any]) -> Any: - """Resolve fn's parameters via markers and call it.""" - sig = inspect.signature(fn) - hints = typing.get_type_hints(fn, include_extras=True) - - fn_id = getattr(fn, "__name__", repr(fn)) - if fn_id in _RESOLVING: - chain = " -> ".join([*_RESOLVING, fn_id]) - msg = f"hm: dependency cycle detected: {chain}" - raise RuntimeError(msg) - _RESOLVING.append(fn_id) - try: - kwargs: dict[str, Any] = {} - for name, param in sig.parameters.items(): - ann = hints.get(name) - marker = _marker_for(ann) if ann is not None else None - if marker is _TARGET_MARKER: - if name not in _TARGETS_BY_NAME: - msg = ( - f"hm.Target parameter {name!r} refers to no registered " - f"@hm.target — register one with that name, or pass " - '`name="..."` to disambiguate.' - ) - raise ValueError(msg) - kwargs[name] = _TARGETS_BY_NAME[name]() - elif isinstance(marker, _BaseImageMarker): - kwargs[name] = Step(image=marker.image) - elif marker is _DEP_MARKER: - # Local import to avoid circular: _deploy imports nothing from us. - from ._deploy import DEPLOYMENTS - - if name not in DEPLOYMENTS: - msg = ( - f"hm.Dep parameter {name!r} refers to no registered " - f"@hm.deploy — register one with that slug, or pass " - '`name="..."` to disambiguate.' - ) - raise ValueError(msg) - kwargs[name] = DEPLOYMENTS[name]() - elif param.default is not inspect.Parameter.empty: - kwargs[name] = param.default - else: - msg = ( - f"hm: parameter {name!r} on {fn_id} has no resolution.\n" - " → add a marker or default value." - ) - raise ValueError(msg) - return fn(**kwargs) - finally: - _RESOLVING.pop() -``` - -NB: The body above is the **complete new content** of `harmont/_deps.py`. The diff from the prior version is: imports gain `_DEP_MARKER`/`_DepMarker`; `_marker_for` recognizes them; `call_with_deps` resolves them via `DEPLOYMENTS`. Replace the entire file with this content; do not edit-in-place line by line, since `call_with_deps` and `validate_target_signature` change in coupled ways. - -- [ ] **Step 5: Add `Dep` to top-level `harmont/__init__.py`** - -Read `harmont/__init__.py`, locate the line `from ._typing import BaseImage, Target`, and replace it with: - -```python -from ._typing import BaseImage, Dep, Target -``` - -Add `"Dep"` to the `__all__` list in alphabetical position (between `Pipeline` and `Step`). - -- [ ] **Step 6: Run tests to verify they pass** - -```bash -pytest tests/dev/test_dep_marker.py -v -``` - -Expected: 3 passed. - -Also re-run all existing tests to make sure `call_with_deps` changes didn't regress: - -```bash -pytest -v -``` - -Expected: every prior pass still passes; new tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add harmont/_typing.py harmont/_deps.py harmont/__init__.py tests/dev/test_dep_marker.py -git commit -m "$(cat <<'EOF' -feat(deploy): add hm.Dep[T] marker + extend call_with_deps resolver - -Dep[T] resolves a parameter against harmont._deploy.DEPLOYMENTS by -the parameter name (same shape as Target[T] vs _TARGETS_BY_NAME). -Cycle detection reuses the existing _RESOLVING stack so dep cycles -between deployments and dep cycles between targets share one detector. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 6: `@hm.deploy` decorator - -**Files:** -- Modify: `harmont/_deploy.py` (add the decorator + slug validator) -- Modify: `harmont/__init__.py` (re-export `deploy` — note name-clashes with `hm.dev.deploy`) -- Test: `tests/dev/test_decorator.py` (new) - -The clash: `hm.deploy` (decorator) vs `hm.dev.deploy` (factory). They live in different namespaces and are imported separately. The factory is `harmont.dev.deploy`; the decorator is `harmont.deploy`. The `harmont/__init__.py` re-exports `deploy = harmont._deploy.deploy` so `hm.deploy(...)` resolves to the decorator. `hm.dev.deploy(...)` is reached via the submodule. - -- [ ] **Step 1: Write the failing tests** - -In `tests/dev/test_decorator.py`: - -```python -"""@hm.deploy decorator: registration, slug derivation, fixture injection.""" -from __future__ import annotations - -import pytest - -import harmont as hm -from harmont._deploy import DEPLOYMENTS -from harmont.dev import LocalDeployment - - -def test_deploy_registers_under_explicit_slug(): - @hm.deploy("db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - assert "db" in DEPLOYMENTS - resolved = DEPLOYMENTS["db"]() - assert isinstance(resolved, LocalDeployment) - assert resolved.name == "db" # decorator stamped slug in - assert resolved.image == "postgres:16" - - -def test_deploy_uses_function_name_when_slug_omitted(): - @hm.deploy() - def redis(): - return hm.dev.deploy(image="redis:7", port_mapping={6379: hm.dev.port()}) - - assert "redis" in DEPLOYMENTS - - -def test_deploy_rejects_invalid_slug(): - with pytest.raises(ValueError, match="invalid deployment slug"): - @hm.deploy("Bad Slug") - def x(): - return hm.dev.deploy(image="x", port_mapping={5432: hm.dev.port()}) - - -def test_deploy_rejects_duplicate_slug(): - @hm.deploy("db") - def db1(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - with pytest.raises(ValueError, match="duplicate deployment slug"): - @hm.deploy("db") - def db2(): - return hm.dev.deploy(image="postgres:15", port_mapping={5432: hm.dev.port()}) - - -def test_deploy_requires_marker_on_param(): - with pytest.raises(ValueError, match=r"parameter 'db' on .* must carry a marker"): - @hm.deploy("api") - def api(db): # type: ignore[no-untyped-def] - return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) - - -def test_deploy_injects_dep_value(): - @hm.deploy("db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - @hm.deploy("api") - def api(db: hm.Dep[hm.Deployment]): - # db.name comes from the resolved upstream Deployment - return hm.dev.deploy( - image="x", - port_mapping={8000: hm.dev.port()}, - env={"DB_HOST": db.name}, - ) - - resolved = DEPLOYMENTS["api"]() - assert resolved.env["DB_HOST"] == "db" - - -def test_deploy_with_explicit_name_arg(): - @hm.deploy("db", name="primary-db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - # The display name is held alongside the slug; the registry is keyed by slug. - assert "db" in DEPLOYMENTS - # In v1 we don't expose `name` separately on the returned Deployment; - # the slug IS the public identity. The kwarg is reserved for future use. - - -def test_deploy_function_can_return_remote_driver_value(): - # Simulate a future driver: a function that returns a Deployment with - # driver != "local". The decorator must register it without complaint. - from harmont._deploy import Deployment - - @hm.deploy("prod-api") - def prod_api(): - return Deployment(name="", driver="aws") - - resolved = DEPLOYMENTS["prod-api"]() - assert resolved.driver == "aws" - assert resolved.name == "prod-api" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_decorator.py -v -``` - -Expected: `AttributeError: module 'harmont' has no attribute 'deploy'`. - -- [ ] **Step 3: Implement `@hm.deploy` in `harmont/_deploy.py`** - -Replace the entire `harmont/_deploy.py` content with: - -```python -"""Driver-agnostic deployment registry, decorator, and Dep marker. - -This module is intentionally driver-free. Concrete deployment types -(``LocalDeployment``, future ``AwsDeployment``, …) live in their own -driver subpackages (``harmont.dev``, future ``harmont.aws``). -""" -from __future__ import annotations - -import dataclasses -import re -from dataclasses import dataclass -from functools import wraps -from typing import TYPE_CHECKING, Any - -from ._deps import call_with_deps, validate_target_signature - -if TYPE_CHECKING: - from collections.abc import Callable - - -@dataclass(frozen=True) -class Deployment: - """Abstract deployment record. Subclassed per driver.""" - name: str - driver: str - - -DEPLOYMENTS: dict[str, "Callable[[], Deployment]"] = {} - - -_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$") - - -def _validate_slug(slug: str) -> None: - if not _SLUG_RE.match(slug): - msg = ( - f"hm: invalid deployment slug {slug!r}\n" - " → use lowercase letters, digits, and '-', " - "start with a letter, max 31 chars (Docker container name rules)" - ) - raise ValueError(msg) - - -def deploy( - slug: str | None = None, - *, - name: str | None = None, -) -> "Callable[[Callable[..., Any]], Callable[[], Deployment]]": - """Register a function as a deployment. - - The wrapped function returns a :class:`Deployment` (typically the - output of :func:`harmont.dev.deploy` or any future driver's factory). - Parameters are resolved via the markers used by ``@hm.target`` and - ``@hm.pipeline``, plus ``hm.Dep[T]`` for deployment-to-deployment - references. See ``docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md``. - """ - - def decorator(fn: "Callable[..., Any]") -> "Callable[[], Deployment]": - validate_target_signature(fn) - resolved_slug = slug if slug is not None else fn.__name__ - _validate_slug(resolved_slug) - if resolved_slug in DEPLOYMENTS: - msg = ( - f"hm: duplicate deployment slug {resolved_slug!r}\n" - " → each @hm.deploy must have a unique slug; pass an " - "explicit slug or `name=\"...\"` to disambiguate" - ) - raise ValueError(msg) - - @wraps(fn) - def wrapper() -> Deployment: - value = call_with_deps(fn) - if not isinstance(value, Deployment): - msg = ( - f"hm.deploy({resolved_slug!r}) must return a Deployment, " - f"got {type(value).__name__}\n" - " → return the output of hm.dev.deploy(...) or another " - "driver's factory" - ) - raise TypeError(msg) - # Stamp the slug into the returned dataclass. - return dataclasses.replace(value, name=resolved_slug) - - DEPLOYMENTS[resolved_slug] = wrapper - return wrapper - - return decorator -``` - -- [ ] **Step 4: Re-export `deploy`, `Dep`, `Deployment` from `harmont/__init__.py`** - -Read `harmont/__init__.py`. After the existing imports add: - -```python -from ._deploy import Deployment, deploy -``` - -And re-export `dev` as a submodule. Find the `from . import _decorator` line and add right after it: - -```python -from . import dev -``` - -Update the `__all__` list to include (in alphabetical position): `"Dep"`, `"Deployment"`, `"deploy"`, `"dev"`. - -The final `__all__` should look like (sorted): - -```python -__all__ = [ - "BaseImage", - "CacheCompose", - "CacheForever", - "CacheNone", - "CacheOnChange", - "CachePolicy", - "CacheTTL", - "Dep", - "Deployment", - "Pipeline", - "Step", - "Target", - "cmake", - "compose", - "composer", - "deploy", - "dev", - "dotnet", - "dump_registry_json", - "elm", - "forever", - "go", - "gradle", - "haskell", - "npm", - "ocaml", - "on_change", - "perl", - "pipeline", - "pipeline_to_json", - "pull_request", - "push", - "python", - "ruby", - "rust", - "schedule", - "scratch", - "sh", - "target", - "ttl", - "wait", - "zig", -] -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -pytest tests/dev/test_decorator.py -v -``` - -Expected: 8 passed. - -Also run the full suite to confirm no regressions: - -```bash -pytest -v -``` - -Expected: every pre-existing test still passes; new tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add harmont/_deploy.py harmont/__init__.py tests/dev/test_decorator.py -git commit -m "$(cat <<'EOF' -feat(deploy): add @hm.deploy decorator with slug validation + Dep injection - -Decorator validates the slug regex (Docker container-name rules), -rejects duplicates, validates the function signature via the existing -validate_target_signature, and wraps the function so call_with_deps -resolves Target/Dep/BaseImage markers at registry-walk time. - -dataclasses.replace stamps the resolved slug into the returned -Deployment so the value seen by callers and the registry has -name= (the factory leaves name=""). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 7: Topo sort + dep-graph extraction - -The registry dumper (Task 8) needs to walk deployments in dependency order. This task adds a pure dep-graph extractor + topo sort. No JSON yet. - -**Files:** -- Modify: `harmont/_deploy.py` (add `dep_graph` + `topo_order`) -- Test: `tests/dev/test_topo.py` (new) - -- [ ] **Step 1: Write the failing tests** - -In `tests/dev/test_topo.py`: - -```python -"""dep_graph extraction + topo_order on the deployment registry.""" -from __future__ import annotations - -import pytest - -import harmont as hm -from harmont._deploy import dep_graph, topo_order - - -def test_dep_graph_empty_when_no_deps(): - @hm.deploy("db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - g = dep_graph() - assert g == {"db": ()} - - -def test_dep_graph_lists_param_names_in_order(): - @hm.deploy("db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - @hm.deploy("api") - def api(db: hm.Dep[hm.Deployment]): - return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}, - env={"DB": db.name}) - - g = dep_graph() - assert g == {"db": (), "api": ("db",)} - - -def test_topo_order_is_stable_and_deps_first(): - @hm.deploy("db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - @hm.deploy("api") - def api(db: hm.Dep[hm.Deployment]): - return hm.dev.deploy(image="x", port_mapping={8000: hm.dev.port()}) - - @hm.deploy("web") - def web(api: hm.Dep[hm.Deployment]): - return hm.dev.deploy(image="x", port_mapping={3000: hm.dev.port()}) - - order = topo_order() - # db before api before web - assert order.index("db") < order.index("api") < order.index("web") - - -def test_topo_order_raises_on_cycle(): - # Build the cycle directly in the registry (bypasses normal decoration - # ordering, since at decoration time the upstream may not yet be - # registered — we only want to test the detector here). - from harmont._deploy import DEPLOYMENTS, Deployment - - @hm.deploy("a") - def a(b: hm.Dep[hm.Deployment]): - return Deployment(name="", driver="local") - - @hm.deploy("b") - def b(a: hm.Dep[hm.Deployment]): - return Deployment(name="", driver="local") - - with pytest.raises(RuntimeError, match="dep cycle"): - topo_order() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_topo.py -v -``` - -Expected: ImportError on `dep_graph` / `topo_order` from `harmont._deploy`. - -- [ ] **Step 3: Implement `dep_graph` and `topo_order` in `harmont/_deploy.py`** - -Append to `harmont/_deploy.py`: - -```python -def dep_graph() -> dict[str, tuple[str, ...]]: - """Return slug -> tuple of upstream slugs, in parameter order. - - Walks DEPLOYMENTS; for each registered slug, introspects the wrapped - function's signature for ``Dep[T]`` parameters. Plain defaults and - Target/BaseImage markers do not produce edges in the deploy graph. - """ - import inspect - import typing as _typing - - from ._typing import _DEP_MARKER - - out: dict[str, tuple[str, ...]] = {} - for slug, wrapper in DEPLOYMENTS.items(): - fn = wrapper.__wrapped__ # type: ignore[attr-defined] - sig = inspect.signature(fn) - hints = _typing.get_type_hints(fn, include_extras=True) - deps: list[str] = [] - for name in sig.parameters: - ann = hints.get(name) - if ann is None: - continue - if _typing.get_origin(ann) is None: - continue - metadata = _typing.get_args(ann)[1:] - if any(m is _DEP_MARKER for m in metadata): - deps.append(name) - out[slug] = tuple(deps) - return out - - -def topo_order() -> list[str]: - """Topological ordering of DEPLOYMENTS by dep_graph; deps first. - - Raises RuntimeError on cycles. Stable under insertion order for - independent slugs (preserves decoration order within a level). - """ - g = dep_graph() - # Kahn's algorithm w/ stable level ordering (insertion-order). - indeg: dict[str, int] = {slug: 0 for slug in g} - for upstreams in g.values(): - for u in upstreams: - if u in indeg: - indeg[u] # downstream depends on u; u has no incoming from here - # incoming edge is into the *dependent* slug, not the upstream. - # Rebuild: indeg of S = number of deps of S that exist in the registry. - for slug, upstreams in g.items(): - indeg[slug] = sum(1 for u in upstreams if u in g) - order: list[str] = [] - # Iterate in registry insertion order so the result is stable. - while True: - progressed = False - for slug in list(g.keys()): - if slug in order: - continue - if indeg[slug] == 0: - order.append(slug) - for downstream, upstreams in g.items(): - if slug in upstreams and downstream not in order: - indeg[downstream] -= 1 - progressed = True - if not progressed: - break - if len(order) != len(g): - unresolved = [s for s in g if s not in order] - msg = ( - f"hm: dep cycle among deployments: {', '.join(unresolved)}\n" - " → break the cycle, or factor shared state into a target" - ) - raise RuntimeError(msg) - return order -``` - -NB: The above intentionally keeps the implementation small and obvious (no graph library). The `__wrapped__` attribute is set by `functools.wraps` in the decorator, so the introspection finds the original function's signature. - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -pytest tests/dev/test_topo.py -v -``` - -Expected: 4 passed. - -- [ ] **Step 5: Commit** - -```bash -git add harmont/_deploy.py tests/dev/test_topo.py -git commit -m "$(cat <<'EOF' -feat(deploy): add dep_graph + topo_order over DEPLOYMENTS - -dep_graph walks the registry, introspects each wrapped function for -Dep[T] params, and emits slug -> tuple of upstream slugs in parameter -order. topo_order runs Kahn's algorithm with stable level ordering -(insertion order within a level) so the registry-dump output is -deterministic. Cycle detection raises RuntimeError listing the -unresolved slugs. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 8: `dump_registry_json` for the local driver - -**Files:** -- Create: `harmont/dev/_registry_dump.py` -- Modify: `harmont/dev/__init__.py` -- Test: `tests/dev/test_registry_dump.py` - -- [ ] **Step 1: Write the failing tests** - -In `tests/dev/test_registry_dump.py`: - -```python -"""dump_registry_json — golden JSON shape for canonical examples.""" -from __future__ import annotations - -import json -from pathlib import Path - -import harmont as hm -from harmont._deploy import Deployment -from harmont.dev import dump_registry_json - - -def test_dump_minimal_local_deployment(): - @hm.deploy("db") - def db(): - return hm.dev.deploy( - image="postgres:16", - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, - ) - - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) - assert out["schema_version"] == "0" - assert out["worktree"] == "/tmp/wt" - assert out["deployments"]["db"] == { - "driver": "local", - "image": "postgres:16", - "from": None, - "cmd": None, - "port_mapping": {"5432": "__hm_dev_port__"}, - "env": {"POSTGRES_PASSWORD": "dev"}, - "volumes": {}, - "workdir": None, - "deps": [], - } - - -def test_dump_with_cmd_workdir_volumes(): - @hm.deploy("db") - def db(): - return hm.dev.deploy( - image="postgres:16", - cmd=["postgres", "-c", "shared_buffers=128MB"], - port_mapping={5432: hm.dev.port()}, - volumes={".": "/workspace"}, - workdir="/workspace", - ) - - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) - e = out["deployments"]["db"] - assert e["cmd"] == ["postgres", "-c", "shared_buffers=128MB"] - assert e["workdir"] == "/workspace" - assert e["volumes"] == {".": "/workspace"} - - -def test_dump_with_deps_emits_deps_array_in_param_order(): - @hm.deploy("db") - def db(): - return hm.dev.deploy(image="postgres:16", port_mapping={5432: hm.dev.port()}) - - @hm.deploy("api") - def api(db: hm.Dep[hm.Deployment]): - return hm.dev.deploy( - image="x", port_mapping={8000: hm.dev.port()}, - env={"DB_HOST": db.name}, - ) - - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) - assert out["deployments"]["api"]["deps"] == ["db"] - assert out["deployments"]["api"]["env"] == {"DB_HOST": "db"} - - -def test_dump_step_chain_emits_pipeline_v0_ir(): - @hm.deploy("api") - def api(): - return hm.dev.deploy( - from_=hm.sh("echo build", image="alpine:3.20"), - port_mapping={8000: hm.dev.port()}, - ) - - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) - f = out["deployments"]["api"]["from"] - assert f["type"] == "step_chain" - assert f["pipeline_v0"]["version"] == "0" - assert f["pipeline_v0"]["steps"][0]["cmd"] == "echo build" - - -def test_dump_non_local_driver_is_marked_unhandled(): - @hm.deploy("prod-api") - def prod_api(): - # Future drivers will produce their own subclasses; for the v1 - # registry-dump test we use the abstract Deployment with a - # non-"local" driver to simulate the shape. - return Deployment(name="", driver="aws") - - out = json.loads(dump_registry_json(worktree_root=Path("/tmp/wt"))) - assert out["deployments"]["prod-api"] == {"driver": "aws", "_unhandled": True} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_registry_dump.py -v -``` - -Expected: ImportError on `dump_registry_json` from `harmont.dev`. - -- [ ] **Step 3: Implement `harmont/dev/_registry_dump.py`** - -```python -"""Local-driver registry dump. - -Walks ``harmont._deploy.DEPLOYMENTS`` in topo order, lowering each -``LocalDeployment`` to the JSON shape described in -``docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md`` § 1. -Non-local deployments are passed through as ``{"driver": X, -"_unhandled": true}`` so the CLI can render them in ``hm dev ls``. - -Step-chain deployments emit their pipeline as the existing v0 IR via -``harmont.pipeline()``; cache-keys are resolved through -``harmont.pipeline_to_json``'s standard path. -""" -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Any - -from harmont._deploy import DEPLOYMENTS, Deployment, dep_graph, topo_order -from harmont._target import clear_target_memo -from harmont.pipeline import pipeline as _assemble -from harmont.keygen import resolve_pipeline_keys - -from ._deployment import LocalDeployment -from ._port import _PortSentinel - -if TYPE_CHECKING: - from pathlib import Path - - -_SENTINEL_WIRE = "__hm_dev_port__" - - -def _lower_local(d: LocalDeployment, deps: tuple[str, ...]) -> dict[str, Any]: - return { - "driver": "local", - "image": d.image, - "from": _lower_from_step(d.from_step) if d.from_step is not None else None, - "cmd": list(d.cmd) if d.cmd is not None else None, - "port_mapping": { - str(cport): _SENTINEL_WIRE - for cport, value in d.port_mapping.items() - if isinstance(value, _PortSentinel) - }, - "env": dict(d.env), - "volumes": dict(d.volumes), - "workdir": d.workdir, - "deps": list(deps), - } - - -def _lower_from_step(step: Any) -> dict[str, Any]: - """Lower a single Step (the deployment's `from_=`) into the v0 IR shape. - - The Step is treated as the terminal leaf of a one-pipeline IR. - Cache-keys are resolved via the existing keygen so the Rust side - can use them as image tags without re-running the algorithm. - """ - ir = _assemble(step) - resolve_pipeline_keys( - ir.get("steps", []), - pipeline_org="hm-dev", - pipeline_slug="hm-dev-build", - now=0, - base_path=None, - env={}, - ) - return {"type": "step_chain", "pipeline_v0": ir} - - -def dump_registry_json( - *, - worktree_root: "Path | None" = None, -) -> str: - """Emit the v0 deployment-registry JSON. - - ``worktree_root`` is recorded so the CLI can resolve relative - ``volumes`` paths and the worktree-hash label. Pass the value - yourself in tests; production use comes through the CLI shim - (``python -m harmont.dev --dump-registry --worktree-root ``). - """ - from pathlib import Path as _Path - - clear_target_memo() - wt = _Path(worktree_root) if worktree_root is not None else _Path.cwd() - order = topo_order() - graph = dep_graph() - deployments: dict[str, dict[str, Any]] = {} - for slug in order: - value = DEPLOYMENTS[slug]() - if isinstance(value, LocalDeployment): - deployments[slug] = _lower_local(value, graph[slug]) - elif isinstance(value, Deployment): - deployments[slug] = {"driver": value.driver, "_unhandled": True} - else: - msg = ( - f"hm: @hm.deploy({slug!r}) returned {type(value).__name__}; " - "expected a Deployment subclass" - ) - raise TypeError(msg) - return json.dumps({ - "schema_version": "0", - "worktree": str(wt), - "deployments": deployments, - }) -``` - -- [ ] **Step 4: Re-export from `harmont/dev/__init__.py`** - -Replace `harmont/dev/__init__.py` with the final v1 content: - -```python -"""harmont.dev — local Docker deployment driver. - -Public surface: - - deploy(*, image=None, from_=None, cmd=None, - port_mapping=None, env=None, - volumes=None, workdir=None) -> LocalDeployment - port() -> _PortSentinel - LocalDeployment (concrete subclass) - dump_registry_json(*, worktree_root) -> str -""" -from __future__ import annotations - -from ._deployment import LocalDeployment -from ._factory import deploy -from ._port import _PortSentinel, port -from ._registry_dump import dump_registry_json - -__all__ = ["LocalDeployment", "_PortSentinel", "deploy", "dump_registry_json", "port"] -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -pytest tests/dev/test_registry_dump.py -v -``` - -Expected: 5 passed. - -- [ ] **Step 6: Commit** - -```bash -git add harmont/dev/_registry_dump.py harmont/dev/__init__.py tests/dev/test_registry_dump.py -git commit -m "$(cat <<'EOF' -feat(dev): dump_registry_json emits the v0 deployment IR for the CLI - -Walks DEPLOYMENTS in topo order, lowering LocalDeployment values to -the schema documented in the spec (§ 1). Step-chain from_= values are -lowered via the existing harmont.pipeline() + keygen pipeline so the -Rust executor can run the chain and use the terminal key as the -build-image tag. Non-local drivers are passed through as -{"driver": X, "_unhandled": true} for hm dev ls. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 9: `python -m harmont.dev --dump-registry` CLI shim - -The Rust CLI spawns this subprocess to read the deployment registry. It walks `.harmont/*.py`, imports each (side-effect registration), then prints the registry JSON to stdout. - -**Files:** -- Create: `harmont/dev/__main__.py` -- Test: `tests/dev/test_dump_cli.py` - -- [ ] **Step 1: Write the failing test** - -In `tests/dev/test_dump_cli.py`: - -```python -"""`python -m harmont.dev --dump-registry` integration.""" -from __future__ import annotations - -import json -import subprocess -import sys -import textwrap -from pathlib import Path - - -def test_dump_cli_walks_harmont_dir_and_prints_registry(tmp_path: Path): - pkg = tmp_path / ".harmont" - pkg.mkdir() - (pkg / "deploys.py").write_text(textwrap.dedent(""" - import harmont as hm - - @hm.deploy("db") - def db(): - return hm.dev.deploy( - image="postgres:16", - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, - ) - """)) - result = subprocess.run( - [sys.executable, "-m", "harmont.dev", "--dump-registry"], - cwd=tmp_path, - capture_output=True, - text=True, - check=True, - ) - out = json.loads(result.stdout) - assert out["schema_version"] == "0" - assert out["worktree"] == str(tmp_path) - assert "db" in out["deployments"] - assert out["deployments"]["db"]["image"] == "postgres:16" - - -def test_dump_cli_errors_when_no_harmont_dir(tmp_path: Path): - result = subprocess.run( - [sys.executable, "-m", "harmont.dev", "--dump-registry"], - cwd=tmp_path, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "no .harmont/ directory" in result.stderr - - -def test_dump_cli_errors_on_bad_argument(tmp_path: Path): - result = subprocess.run( - [sys.executable, "-m", "harmont.dev", "--no-such-flag"], - cwd=tmp_path, - capture_output=True, - text=True, - ) - assert result.returncode == 2 # argparse default -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/dev/test_dump_cli.py -v -``` - -Expected: `ModuleNotFoundError: No module named 'harmont.dev.__main__'`. - -- [ ] **Step 3: Implement `harmont/dev/__main__.py`** - -```python -"""`python -m harmont.dev` — registry-dump entry point for the CLI. - -Walks ``.harmont/*.py`` (importing each by file path), letting -``@hm.deploy``-decorated functions register themselves into -``harmont._deploy.DEPLOYMENTS`` as a side effect. Then emits the -deployment registry JSON to stdout. - -Errors go to stderr with exit code 1 (DSL error) or 2 (argparse -usage error), matching ``harmont``'s convention. -""" -from __future__ import annotations - -import argparse -import importlib.util -import sys -from pathlib import Path - - -def _import_path(path: Path) -> None: - spec = importlib.util.spec_from_file_location( - name=f"_harmont_dev_user_{path.stem}", - location=str(path), - ) - if spec is None or spec.loader is None: - raise RuntimeError(f"cannot load module from {path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - -def _walk_harmont_dir(root: Path) -> None: - harmont_dir = root / ".harmont" - if not harmont_dir.is_dir(): - print( - f"hm: no .harmont/ directory in {root}\n" - " → create .harmont/ and add @hm.deploy-decorated functions", - file=sys.stderr, - ) - sys.exit(1) - for py in sorted(harmont_dir.glob("*.py")): - _import_path(py) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="python -m harmont.dev") - parser.add_argument( - "--dump-registry", - action="store_true", - help="walk .harmont/*.py and emit the v0 deployment registry JSON", - ) - parser.add_argument( - "--worktree-root", - type=Path, - default=None, - help="path to the worktree root; defaults to cwd", - ) - args = parser.parse_args(argv) - - if not args.dump_registry: - parser.error("nothing to do; pass --dump-registry") - return 2 - - from harmont.dev import dump_registry_json - - root = args.worktree_root if args.worktree_root is not None else Path.cwd() - _walk_harmont_dir(root) - print(dump_registry_json(worktree_root=root)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -pytest tests/dev/test_dump_cli.py -v -``` - -Expected: 3 passed. - -- [ ] **Step 5: Commit** - -```bash -git add harmont/dev/__main__.py tests/dev/test_dump_cli.py -git commit -m "$(cat <<'EOF' -feat(dev): python -m harmont.dev --dump-registry CLI shim - -Walks .harmont/*.py, imports each by file path so @hm.deploy -registrations land in harmont._deploy.DEPLOYMENTS, then prints the -deployment registry JSON to stdout. The Rust CLI invokes this and -deserializes via serde (see harmont-cli plan). - -Missing .harmont/ exits 1 with a fix-directed stderr. Argparse handles -usage errors with exit 2. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 10: Update `CLAUDE.md` public-surface documentation - -**Files:** -- Modify: `CLAUDE.md` - -- [ ] **Step 1: Append deployments section to `CLAUDE.md`** - -Read `CLAUDE.md`. Add the following section immediately before `## Cache keys` (the existing section): - -````markdown -## Deployments — `@hm.deploy` and `hm.dev` - -`@hm.deploy` is a driver-agnostic decorator that registers a function -as a long-lived service. The function returns a `Deployment` value -produced by a driver-specific factory; v1 ships only the local Docker -driver via `hm.dev.deploy(...)`. Future cloud drivers (`hm.aws.deploy`, -`hm.fly.deploy`) plug in without touching the top-level decorator. - -```python -import harmont as hm - -@hm.deploy("db") -def db() -> hm.Deployment: - return hm.dev.deploy( - image="postgres:16", - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, - ) - -@hm.deploy("api") -def api( - db: hm.Dep[hm.Deployment], - api_image: hm.Target[hm.Step], -) -> hm.Deployment: - return hm.dev.deploy( - from_=api_image, - port_mapping={8000: hm.dev.port()}, - env={"DATABASE_URL": f"postgres://{db.name}:5432/app"}, - ) -``` - -Public surface: - -```python -hm.deploy(slug=None, *, name=None) # decorator -hm.Dep[T] # PEP-593 fixture marker -hm.Deployment # abstract dataclass - -hm.dev.deploy(*, image=None, from_=None, cmd=None, - port_mapping=None, env=None, - volumes=None, workdir=None) # -> LocalDeployment -hm.dev.port() # OS-assigned host port sentinel -hm.dev.LocalDeployment # concrete subclass -hm.dev.dump_registry_json(*, worktree_root) # -> v0 JSON -``` - -`hm.dev.port()` is only valid as a value in `port_mapping`. The host -port is assigned by Docker (via `-p :`) at `hm dev up` -time; query it from another terminal with `hm dev port-of -`. Ports are fresh on every `hm dev up`. - -The Rust CLI (`hm dev up`) shells out to `python -m harmont.dev ---dump-registry` to obtain the registry JSON. Schema is at -`docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md` § 1. -```` - -- [ ] **Step 2: Sanity-check the doc compiles in your head** - -Re-read CLAUDE.md top-to-bottom. Confirm no stale references and that the new section sits between the pipeline surface and the cache-key section. - -- [ ] **Step 3: Commit** - -```bash -git add CLAUDE.md -git commit -m "$(cat <<'EOF' -docs: document hm.deploy + hm.dev in CLAUDE.md - -Adds the deployments section to the agent-facing doc with the -canonical example and the full public surface. Cross-links the -design spec for engineers who need the wire-format details. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 11: Full-suite green + canonical end-to-end sanity check - -A final integration test that mirrors the spec's canonical example. Confirms every piece works together. - -**Files:** -- Test: `tests/dev/test_canonical_example.py` (new) - -- [ ] **Step 1: Write the integration test** - -In `tests/dev/test_canonical_example.py`: - -```python -def test_canonical_hello_greeter_dumps_expected_shape(): - @hm.deploy("hello") - def hello() -> hm.Deployment: - return hm.dev.deploy( - image="python:3.12-alpine", - cmd=["python", "-m", "http.server", "5678"], - port_mapping={5678: hm.dev.port()}, - ) - - @hm.deploy("greeter") - def greeter(hello: hm.Dep[hm.Deployment]) -> hm.Deployment: - return hm.dev.deploy( - image="python:3.12-alpine", - cmd=["python", "-m", "http.server", "5678"], - port_mapping={5678: hm.dev.port()}, - env={"HELLO_HOST": hello.name}, - ) - - raw = hm.dev.dump_registry_json(worktree_root=Path("/tmp/wt")) - out = json.loads(raw) - assert out["schema_version"] == "0" - assert list(out["deployments"].keys()) == ["hello", "greeter"] # topo order - assert out["deployments"]["greeter"]["deps"] == ["hello"] - assert out["deployments"]["hello"]["image"] == "python:3.12-alpine" - assert out["deployments"]["hello"]["cmd"] == [ - "python", "-m", "http.server", "5678", - ] - assert out["deployments"]["greeter"]["env"] == {"HELLO_HOST": "hello"} - # No Step-chain in the new example (from_= is stubbed in v1 cli); - # both entries have from=None. - assert out["deployments"]["hello"]["from"] is None - assert out["deployments"]["greeter"]["from"] is None -``` - -- [ ] **Step 2: Run only this test** - -```bash -pytest tests/dev/test_canonical_example.py -v -``` - -Expected: 1 passed. - -- [ ] **Step 3: Run the full suite to confirm zero regressions** - -```bash -pytest -v -``` - -Expected: every test passes. If any pre-existing test fails, investigate `harmont/_deps.py` changes — they're the only cross-cutting modification. - -- [ ] **Step 4: Run lint + type-check (matches CLAUDE.md gate)** - -```bash -ruff check . -mypy harmont tests -``` - -Expected: both pass cleanly. The new code is fully type-annotated; if mypy complains, fix the annotations before committing — do not suppress. - -- [ ] **Step 5: Commit** - -```bash -git add tests/dev/test_canonical_example.py -git commit -m "$(cat <<'EOF' -test(dev): end-to-end canonical db+api+web example - -Mirrors the spec's worked example. Asserts topo order, dep edges, -cross-deploy f-string env values, and that from_=Step lowers through -the existing v0 IR pipeline. This is the "vibe check" gate before -the CLI plan can start consuming the JSON output. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 12: PR-readiness sanity pass - -**Files:** none modified. - -- [ ] **Step 1: Branch is up to date with main** - -```bash -git fetch origin main -git log --oneline origin/main..HEAD -``` - -Expected: a clean linear history of task commits. - -- [ ] **Step 2: Confirm public surface end-to-end** - -```bash -python -c " -import harmont as hm -print(hm.deploy, hm.Dep, hm.Deployment) -print(hm.dev.deploy, hm.dev.port, hm.dev.LocalDeployment, hm.dev.dump_registry_json) -" -``` - -Expected: every name prints without ImportError. If any fails, locate the missing re-export. - -- [ ] **Step 3: Run the dump CLI shim against the canonical example** - -```bash -mkdir /tmp/hm-deploy-smoke && cd /tmp/hm-deploy-smoke && mkdir .harmont && cat > .harmont/deploys.py <<'EOF' -import harmont as hm - -@hm.deploy("db") -def db(): - return hm.dev.deploy( - image="postgres:16", - port_mapping={5432: hm.dev.port()}, - env={"POSTGRES_PASSWORD": "dev"}, - ) -EOF -python -m harmont.dev --dump-registry | python -m json.tool -``` - -Expected: a pretty-printed JSON document matching the spec's schema. - -- [ ] **Step 4: Commit ANY follow-up fixes (none if smoke is clean)** - -If you tweak anything in this pass, commit it with subject `chore: PR-readiness sanity pass`. Otherwise skip. - -- [ ] **Step 5: Done — branch ready for review** - -The `feat/hm-dev-deploy` branch on harmont-py is now feature-complete for v1. The harmont-cli plan (`/home/marko/harmont-cli/docs/superpowers/plans/2026-05-21-hm-dev-deploy-cli.md`) is the natural follow-up; the cli plan assumes harmont-py from this branch is installed (e.g., `pip install -e ../harmont-py` in the cli test environment). - ---- - -## Self-Review Notes (for the plan author, not the executor) - -Coverage of spec § 1 (DSL surface): -- `hm.deploy` decorator → Task 6. -- `hm.Dep[T]` marker → Task 5. -- `hm.Deployment` abstract type → Task 1. -- `hm.dev.deploy(...)` factory → Task 4. -- `hm.dev.port()` sentinel → Task 2. -- `LocalDeployment` dataclass → Task 3. -- `dump_registry_json` → Task 8. -- `python -m harmont.dev --dump-registry` shim → Task 9. -- Validation rules (slug regex, port_mapping shape, env value types, volumes container-path, workdir absolute, exactly-one-of image/from_) → Tasks 4, 6. -- Fixture-injection rules (param must have marker or default; cycles raise) → Tasks 5, 6, 7. - -Coverage of spec § 5 (error handling, decoration-time): -- "invalid deployment slug" → Task 6. -- "duplicate deployment slug" → Task 6. -- "hm.dev.deploy requires exactly one of image= or from_=" → Task 4. -- "port_mapping value must be hm.dev.port()" → Task 4. -- "parameter X must carry a marker" → Task 5 (via the extended `validate_target_signature`). -- "dep cycle" → Task 7. - -Not covered by this plan (correctly — they belong in the cli plan): -- Spec § 2 (CLI surface) — entirely cli-side. -- Spec § 3 (runtime / executor) — entirely cli-side. -- Spec § 4 (lifecycle & signals) — entirely cli-side. -- Spec § 5 runtime errors — entirely cli-side. -- Spec § 6 (cli unit + integration tests). - -Type / name consistency: -- `Deployment`, `LocalDeployment`, `deploy`, `Dep`, `port`, `dump_registry_json` are used identically across all tasks. -- `__hm_dev_port__` is the wire encoding everywhere it appears (Task 8 implementation + tests). -- `worktree_root` kwarg on `dump_registry_json` is used identically across Tasks 8, 9, 11. -- `from_step` (LocalDeployment field) vs `from_` (factory kwarg) — intentionally different (Python keyword conflict for the kwarg; the field is a normal attribute). diff --git a/docs/superpowers/plans/2026-05-21-pypi-tag-release-cd.md b/docs/superpowers/plans/2026-05-21-pypi-tag-release-cd.md deleted file mode 100644 index 2ab39c6..0000000 --- a/docs/superpowers/plans/2026-05-21-pypi-tag-release-cd.md +++ /dev/null @@ -1,470 +0,0 @@ -# harmont-py PyPI Tag-Release CD — 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:** Push a `v*` tag to `harmont-dev/harmont-py` → GitHub Actions builds the sdist and wheel, then publishes to PyPI via Trusted Publishing (OIDC; no API tokens stored in the repo). - -**Architecture:** Mirror `harmont-cli/.github/workflows/release.yml` shape — tag-triggered, sed the version from the tag into `pyproject.toml` (which sits at `0.0.0-dev` in main), build via `python -m build`, publish via `pypa/gh-action-pypi-publish@release/v1` (OIDC-based). One repo, one workflow file, no third-party publishing secrets. The action runs inside a GitHub Environment called `release` so PyPI can scope the OIDC trust to that environment. - -**Tech Stack:** GitHub Actions, `actions/checkout@v4`, `actions/setup-python@v5`, `python -m build` (PEP 517), `pypa/gh-action-pypi-publish@release/v1` (PyPI's official OIDC publisher), PyPI Trusted Publishing. - -**Direct-to-main:** Per project convention, commits land on `main` in `/home/marko/harmont-py/`. - -**One-time human prerequisites** (the workflow cannot work until these are done — they are spelled out in Task 4): - -1. Configure a Trusted Publisher on PyPI for the `harmont` project: workflow filename `release.yml`, environment `release`, owner `harmont-dev`, repo `harmont-py`. -2. Create a GitHub Environment named `release` on `harmont-dev/harmont-py` with branch protection (optional but recommended): tags matching `v*` only. - ---- - -## File Map - -### `/home/marko/harmont-py/` - -- **Create:** `.github/workflows/release.yml` — tag-triggered publish workflow. -- **Modify:** `pyproject.toml` — pin `version` to `"0.0.0-dev"` so non-tagged builds carry a clearly-not-released version; the workflow sed's the real version in from the tag at CI time. Mirrors `harmont-cli`'s pattern (every `Cargo.toml` has `0.0.0-dev`). -- **Modify:** `RELEASING.md` — replace the manual `twine upload` flow with the new tag-driven flow. Keep the monorepo subtree-push section unchanged. - -No source code or test changes. - ---- - -## Task 1: Pin pyproject.toml to a dev version - -**Why first:** The workflow's `sed` substitution requires a stable marker to find. Pinning to `"0.0.0-dev"` upfront also keeps anyone who `pip install`s harmont-py from `main` from accidentally getting an artifact labeled with the last released version. - -**Files:** -- Modify: `/home/marko/harmont-py/pyproject.toml` - -- [ ] **Step 1: Edit pyproject.toml** - -Locate the `[project]` block (currently around lines 5–14): - -```toml -[project] -name = "harmont" -version = "0.1.0" -``` - -Change `version = "0.1.0"` to `version = "0.0.0-dev"`. Leave every other field as-is. - -- [ ] **Step 2: Confirm the version string is grep-unique** - -```bash -cd /home/marko/harmont-py -grep -n 'version = "0.0.0-dev"' pyproject.toml -``` - -Expected: one match in `pyproject.toml`. If two or more lines match, the sed in Task 2 will need a more specific anchor — fix the duplicate before continuing. - -- [ ] **Step 3: Confirm imports + tests still work** - -```bash -cd /home/marko/harmont-py -python3 -m pytest -x -q 2>&1 | tail -10 -``` - -Expected: the suite passes (pre-existing failures unrelated to this work — gradle + haskell-CI-only paths — are documented; everything else green). Version is just a metadata string; no runtime code reads it. - -- [ ] **Step 4: Commit** - -```bash -cd /home/marko/harmont-py -git add pyproject.toml -git commit -m "$(cat <<'EOF' -chore: pin pyproject version to 0.0.0-dev - -main-branch builds now carry a clearly-unreleased version marker. -The release.yml workflow (next commit) sed's the real version in -from the v* git tag at publish time, mirroring harmont-cli's -crates.io flow. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Write the release workflow - -**Why next:** This is the load-bearing artifact. Two distinct jobs as in `harmont-cli/.github/workflows/release.yml`: nothing else, single file. - -**Files:** -- Create: `/home/marko/harmont-py/.github/workflows/release.yml` - -- [ ] **Step 1: Create the workflow directory** - -```bash -mkdir -p /home/marko/harmont-py/.github/workflows -``` - -- [ ] **Step 2: Write the workflow file** - -Create `/home/marko/harmont-py/.github/workflows/release.yml`: - -```yaml -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: read - -jobs: - pypi-publish: - name: Publish to PyPI - runs-on: ubuntu-latest - environment: - # PyPI Trusted Publisher is scoped to this environment. Configure - # the matching publisher on https://pypi.org/manage/account/publishing/ - # before the first tag push (see RELEASING.md). - name: release - url: https://pypi.org/project/harmont/ - permissions: - # `id-token: write` is the OIDC switch that pypa/gh-action-pypi-publish - # uses to mint a short-lived token PyPI accepts in lieu of an API token. - id-token: write - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Set version from tag - run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - # Sed only the first match so this is a no-op if pyproject is - # already at the tagged version (a re-run with a corrected tag, - # for instance, shouldn't double-edit). - sed -i '0,/version = "0.0.0-dev"/s//version = "'"$VERSION"'"/' pyproject.toml - grep -n "^version" pyproject.toml - - - name: Install build - run: python -m pip install --upgrade build - - - name: Build sdist and wheel - run: python -m build - - - name: Inspect dist - run: | - ls -la dist/ - # Fail fast if either artifact is missing. - test -f dist/harmont-${VERSION}.tar.gz - test -f dist/harmont-${VERSION}-py3-none-any.whl - - - name: Publish to PyPI via Trusted Publishing - uses: pypa/gh-action-pypi-publish@release/v1 - # No `with:` block needed — the action defaults to using OIDC - # against the project's configured Trusted Publisher when - # `id-token: write` is granted (above). It picks up dist/* by - # default. -``` - -Key design choices, all matching `harmont-cli/release.yml`: - -- **Trigger:** `push.tags: ["v*"]`. Tag-driven, no manual workflow_dispatch path. -- **Permissions:** `contents: read` at the workflow level; `id-token: write` only on the publish job. Minimum surface. -- **Environment `release`:** PyPI's Trusted Publisher binds to this exact environment name. Required. -- **Version-from-tag sed:** `GITHUB_REF_NAME` strips `v`. The `0,/.../s//.../` form replaces only the first match — same idiom as `harmont-cli/release.yml:27-29`. -- **Inspect dist:** Asserts both artifacts exist with the expected name shape before the publish step, so a build regression fails the job with a clear message instead of a confusing "no files to upload." -- **No `with:` on the publish action:** PyPI's recommended config; the action introspects `dist/` and uses OIDC by default. - -- [ ] **Step 3: Lint the workflow yaml** - -```bash -python3 -c "import yaml; yaml.safe_load(open('/home/marko/harmont-py/.github/workflows/release.yml'))" && echo yaml-ok -``` - -Expected: `yaml-ok`. If you have `actionlint` installed, also run it: `actionlint .github/workflows/release.yml`. Don't add actionlint as a new dependency just for this. - -- [ ] **Step 4: Confirm `python -m build` succeeds locally** - -```bash -cd /home/marko/harmont-py -python3 -m pip install --upgrade build 2>&1 | tail -3 -python3 -m build 2>&1 | tail -10 -ls dist/ -``` - -Expected: `dist/harmont-0.0.0.dev0.tar.gz` and `dist/harmont-0.0.0.dev0-py3-none-any.whl` (setuptools normalizes `0.0.0-dev` to `0.0.0.dev0`; the dev-version name shape proves the build path works end-to-end). The CI job will see `0.0.0-dev` replaced with the real tag version, so the produced files will be named `harmont-.tar.gz` etc. — that's what the `test -f` checks in the workflow validate. - -After confirming, clean up: - -```bash -rm -rf /home/marko/harmont-py/dist /home/marko/harmont-py/build /home/marko/harmont-py/harmont.egg-info -``` - -- [ ] **Step 5: Commit** - -```bash -cd /home/marko/harmont-py -git add .github/workflows/release.yml -git commit -m "$(cat <<'EOF' -ci: add release.yml — tag-triggered PyPI publish via OIDC - -Mirrors harmont-cli/.github/workflows/release.yml: push a tag -matching v* → GH Actions builds the sdist + wheel, sed's the -version from the tag into pyproject.toml, and publishes to PyPI -using pypa/gh-action-pypi-publish@release/v1 with Trusted -Publishing (OIDC, no API tokens in the repo). - -Runs inside a GH Environment named `release` so PyPI's Trusted -Publisher can scope the OIDC trust. The Publisher must be -configured on PyPI before the first tag push (see RELEASING.md). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Update RELEASING.md to describe the new flow - -**Files:** -- Modify: `/home/marko/harmont-py/RELEASING.md` - -- [ ] **Step 1: Open RELEASING.md and locate the "Cutting a release" section** - -The section currently runs `pytest`/`mypy`/`ruff`, then `python -m build`, then `twine upload dist/*` manually. Replace it wholesale with the tag-driven shape. Keep the "How the mirror is synced" and "Ongoing sync (monorepo → public)" sections — those describe the subtree mirror which is unchanged. - -- [ ] **Step 2: Rewrite the "Cutting a release" section** - -Replace lines starting at `## Cutting a release` through the end of the file with: - -```markdown -## Cutting a release - -Versioning is **driven by git tags on the public mirror**. The release -workflow in `.github/workflows/release.yml` triggers on any tag matching -`v*`, seds the version from the tag into `pyproject.toml`, builds the -sdist and wheel, and publishes to PyPI via Trusted Publishing (OIDC — -no API tokens stored in the repo). - -### Prerequisites (one-time) - -1. **Configure the PyPI Trusted Publisher** on - with: - - Owner: `harmont-dev` - - Repository: `harmont-py` - - Workflow filename: `release.yml` - - Environment: `release` - - If the `harmont` project does not yet exist on PyPI, create it via a - one-off manual `twine upload` first (or use the "Add a pending - publisher" flow at ), - then add the Trusted Publisher. - -2. **Create the `release` GitHub Environment** on - . - Recommended protection rules: - - Deployment branches and tags → "Selected branches and tags" → - add tag rule `v*`. - - (Optional) required reviewers on the environment so a human has - to click "approve" before publish runs. - -### Releasing - -1. Update `CHANGELOG.md` or release notes locally if you keep them. -2. Tag from the monorepo (source of truth): - - ```sh - git tag v - git subtree push --prefix=cidsl/py git@github.com:harmont-dev/harmont-py.git main - git push git@github.com:harmont-dev/harmont-py.git v - ``` - - The tag has to land on the **public** repo for the workflow to fire. - The subtree-push lands the corresponding `main` commit there first - so the tag points at the right SHA. - -3. Watch the run: - - ```sh - gh run watch \ - "$(gh run list --repo harmont-dev/harmont-py --workflow release.yml \ - --limit 1 --json databaseId --jq '.[0].databaseId')" \ - --repo harmont-dev/harmont-py --exit-status - ``` - -4. Confirm the release on . -5. (Optional) Create a GitHub Release on the same tag with notes: - - ```sh - gh release create v --repo harmont-dev/harmont-py \ - --title "harmont v" --generate-notes - ``` - -### Troubleshooting - -- **`Trusted publishing exchange failed`:** the GH Environment name in - the workflow does not match the one configured on PyPI. Both must be - exactly `release`. -- **`File already exists`:** the version was already published to PyPI. - PyPI is append-only — bump the version, re-tag, re-push. -- **`No files to upload`:** the build step did not produce - `dist/*.tar.gz` and `dist/*.whl`. Inspect the `Build sdist and wheel` - step output. Most common cause: `setuptools` couldn't find a package - to build because `pyproject.toml` was mid-edit. -``` - -- [ ] **Step 3: Quick markdown sanity check** - -```bash -cd /home/marko/harmont-py -head -100 RELEASING.md # eyeball the structure -``` - -Confirm: the "How the mirror is synced", "Forcing a manual sync", and "Pulling external contributions back" sections are preserved; "Cutting a release" now describes the tag-driven flow; no stray references to `twine upload` remain. - -- [ ] **Step 4: Commit** - -```bash -cd /home/marko/harmont-py -git add RELEASING.md -git commit -m "$(cat <<'EOF' -docs(releasing): document tag-driven PyPI CD via OIDC - -Replaces the manual `python -m build` + `twine upload` flow with -the new release.yml workflow. Lists the one-time PyPI Trusted -Publisher + GH Environment setup steps and the per-release -`git tag` + `subtree push` sequence. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: One-time PyPI + GitHub Environment setup (HUMAN, not the agent) - -**Why:** The agent cannot click into PyPI's or GitHub's UI. These steps are spelled out so the user runs them once. The workflow will fail until both are in place. - -This is a **manual** task — the agent reports it as DONE without doing anything programmatic. The instructions are repeated here so the executor flags them to the human at the end of the implementation pass. - -- [ ] **Step 1: Verify the project exists on PyPI (or create a pending publisher)** - -Visit . If a "Page not found" appears, the project name is unclaimed. Two paths: - -- **Pending publisher** (preferred — no manual `twine upload` needed): - Go to → "Add a pending - publisher" → fill `harmont`, `harmont-dev`, `harmont-py`, - `release.yml`, `release`. The first successful tag-push will claim - the name and run the publish. - -- **Manual claim:** `python -m build && twine upload dist/*` once with - a personal API token. Then configure the Trusted Publisher (Step 2). - -- [ ] **Step 2: Configure the Trusted Publisher on PyPI** - -If the project already exists, visit -. - -Click "Add a new publisher" → GitHub. Fill exactly: - -- Owner: `harmont-dev` -- Repository name: `harmont-py` -- Workflow name: `release.yml` -- Environment name: `release` - -Save. - -- [ ] **Step 3: Create the `release` GitHub Environment** - -Visit . -Click "New environment" → name it `release` → "Configure environment". - -Set the following protection rules: - -- **Deployment branches and tags:** "Selected branches and tags". Click - "Add deployment branch or tag rule" → choose "Tag" → pattern `v*`. - This prevents anyone from running the publish workflow against a - non-tag ref. -- (Optional) **Required reviewers:** add yourself or a small list. With - reviewers set, every release pauses for human approval before the - publish step runs. Useful for catching accidental tag pushes. - -Save. - -- [ ] **Step 4: Smoke test** - -Don't tag a real release yet. The smoke test goes in Task 5. - ---- - -## Task 5: Push the workflow + version-bump commits to main - -**Why:** After this push, the workflow file is in place on the public repo and the user can tag whenever they're ready. Tagging and watching the publish are explicitly out-of-scope per user direction; they'll handle those steps themselves. - -**Files:** none. - -- [ ] **Step 1: Confirm the staged commits** - -```bash -cd /home/marko/harmont-py -git log --oneline origin/main..HEAD -``` - -Expected: three commits — pyproject pin to 0.0.0-dev, the new release.yml, and the RELEASING.md rewrite. - -- [ ] **Step 2: Push to main** - -```bash -cd /home/marko/harmont-py -git push origin main -``` - -Expected: three commits land on origin/main. After this, the workflow is dormant until a `v*` tag is pushed. - -- [ ] **Step 3: Hand off** - -Report back to the user: -- The three SHAs that landed. -- A reminder of the one-time PyPI Trusted Publisher + GH `release` environment setup (Task 4) that has to happen before the first tag-push. -- The tag-push command the user will run themselves (`git tag v && git push origin v`), so they have it handy. - ---- - -## Out of scope - -- **CI** (running tests on every push/PR). This plan is **CD only**. A - `test.yml` workflow that runs `pytest` on push is a separate concern - — the existing local `pytest` workflow is enough until contributors - arrive. Don't bundle it here. -- **Publishing to TestPyPI as a staging step.** The rc-tag smoke test - in Task 5 is sufficient — it exercises the full real path with a - pre-release version label, which is closer to production than a - separate TestPyPI environment would be. -- **Bumping the harmont-cli CI workflow's `pip install /tmp/harmont-py` - to point at the tagged PyPI release.** Currently CI clones harmont-py - main and pip-installs from source; that path is fine and keeps the - cross-repo feedback loop fast. Switching to PyPI is a follow-up if - someone wants reproducible CI against pinned versions. -- **A custom `build` system other than setuptools.** The existing - `pyproject.toml` uses `setuptools.build_meta`; `python -m build` - honors that. No reason to change. - ---- - -## Self-review - -- **Spec coverage:** Workflow ✓ (Task 2); pyproject pin ✓ (Task 1); docs - ✓ (Task 3); manual prereqs called out ✓ (Task 4); push to main ✓ - (Task 5). Tagging + publishing intentionally out of scope (user - handles). -- **Placeholder scan:** no "TBD", "implement later", "as needed". Every - command has an expected output or a clear next step on failure. -- **Type/name consistency:** environment name `release` is used - identically in (a) the workflow YAML, (b) the PyPI Trusted Publisher - setup, (c) the GitHub Environment creation, (d) the RELEASING.md - prose. Workflow filename `release.yml` is consistent everywhere. - Project name on PyPI is `harmont` (matches `pyproject.toml` - `name = "harmont"` — verified during plan-writing). -- **No `id-token: write` outside the publish job.** Confirmed. From 18d09936f63d5e7971c70db830d0c8bbf6f43828 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 22 May 2026 04:27:02 +0000 Subject: [PATCH 28/28] docs: remove hm-dev-deploy design spec Spec was implementation-time scaffolding; once the code lands the public surface lives in CLAUDE.md (the agent-facing doc) and the code itself. The empty docs/superpowers/{specs,} dirs are also gone (git auto-prunes empty trees). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-21-hm-dev-deploy-design.md | 657 ------------------ 1 file changed, 657 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md diff --git a/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md b/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md deleted file mode 100644 index 8bffa2d..0000000 --- a/docs/superpowers/specs/2026-05-21-hm-dev-deploy-design.md +++ /dev/null @@ -1,657 +0,0 @@ -# `hm.deploy` + `hm dev` Local Deployments — Design Spec - -**Status:** Draft. v1 = local Docker driver only. -**Repos touched:** `harmont-py` (DSL), `harmont-cli` (executor). -**Authors:** Claude + Marko. - ---- - -## Goal - -Let Harmont users declare long-lived, port-mapped local services (Postgres, Redis, an API container, a webapp dev server, …) from the same Python DSL they use for pipelines, and bring them up with one foreground command per worktree. - -## Motivation - -The agentic workflow has one developer (or one agent) per git worktree. Each worktree wants its own Postgres, its own API, its own dev server — running on **globally-unique host ports** so they coexist on one machine. Today this requires hand-curated `docker compose` files with manually-assigned ports, drifting from the canonical CI definition. - -`hm.deploy` + `hm dev up` makes that ergonomic, type-checked, and consistent with the rest of Harmont: - -- Same DSL idioms as `@hm.pipeline` and `@hm.target` (fixture injection, frozen dataclasses, decoration-time validation, fix-directed errors). -- One source of truth: deployments live alongside pipelines in `.harmont/*.py`. -- Each `hm dev up` invocation gets its own ephemeral docker network and a per-session container suffix so multiple sessions in the same worktree don't collide. -- Port assignment is delegated to the OS (`docker -p :CPORT`), so no global registry, no allocator, no lock files. - -## Non-goals (v1) - -- Non-local drivers (`hm.aws`, `hm.fly`, `hm.k8s`). The decorator + abstract type are designed to admit them later; no stubs ship now. -- Daemon-mode / background deployments. -- Healthchecks beyond `Running: true`. -- Cross-session shared state. -- Persistent named volumes (bind mounts only). -- Pipeline ↔ deployment auto-wiring (a test pipeline can't yet declare "needs db up"); deferred. -- Wire-format JSON IR for deployments. v1 hands a Python dict (serialized JSON) from a subprocess to the CLI. Formalized when a second driver lands. - ---- - -## §1 DSL surface - -### Decoupling test - -If `harmont.dev` were deleted entirely, `harmont.deploy` + `harmont.Dep` + `harmont.Deployment` would still compile, the registry would still populate (with deployments nobody can materialize), and `hm dev up` would error cleanly: "no local driver available." That asymmetry is the invariant — top-level is driver-agnostic; everything driver-specific lives in `harmont.dev`. - -### Public surface (full enumeration) - -```python -# harmont/__init__.py — top-level (driver-agnostic) -hm.deploy(slug=None, *, name=None) # decorator -hm.Dep[T] # PEP-593 marker for dep injection -hm.Deployment # abstract dataclass; .name + .driver - -# harmont/dev/__init__.py — local driver -hm.dev.deploy(*, image=None, from_=None, cmd=None, - port_mapping=None, env=None, - volumes=None, workdir=None) # -> LocalDeployment -hm.dev.port() # sentinel: OS picks free host port -hm.dev.LocalDeployment # concrete subclass of Deployment -hm.dev.dump_registry_json() # -> str (driver-filtered for local) -``` - -### Canonical example - -```python -import harmont as hm - -@hm.deploy("hello") -def hello() -> hm.Deployment: - return hm.dev.deploy( - image="python:3.12-alpine", - cmd=["python", "-m", "http.server", "5678"], - port_mapping={5678: hm.dev.port()}, - ) - -@hm.deploy("greeter") -def greeter(hello: hm.Dep[hm.Deployment]) -> hm.Deployment: - return hm.dev.deploy( - image="python:3.12-alpine", - cmd=["python", "-m", "http.server", "5678"], - port_mapping={5678: hm.dev.port()}, - env={"HELLO_HOST": hello.name}, - ) -``` - -### Type hierarchy - -```python -# harmont/_deploy.py -@dataclass(frozen=True) -class Deployment: - name: str - driver: str # discriminator: "local" in v1 - -# harmont/dev/_deployment.py -@dataclass(frozen=True) -class LocalDeployment(Deployment): - image: str | None - from_step: Step | None - cmd: tuple[str, ...] | None - port_mapping: Mapping[int, _PortSentinel] - env: Mapping[str, str] - volumes: Mapping[str, str] - workdir: str | None - # __post_init__ enforces driver == "local" -``` - -### Fixture injection (parameters) - -A `@hm.deploy`-decorated function's parameters carry typed markers, just like `@hm.target`: - -- `hm.Dep[hm.Deployment]` — declares a dependency on another `@hm.deploy` by parameter name. The injected value is a `Deployment` with `.name` already resolved (the slug). The decorator builds the dep graph from these markers. -- `hm.Target[T]` — same `@hm.target` machinery already used by pipelines. - -Rules (decoration-time): - -- Every parameter must carry a marker or have a default; otherwise `ValueError` at decoration time, fix-directed message. -- `*args` / `**kwargs` / positional-only params are rejected. -- Duplicate slugs across `@hm.deploy` raise at decoration time (`hm.deploy(name="…")` is the disambiguation hatch — mirrors `@hm.target`). -- Dep cycles raise `RuntimeError` listing the path. - -### Validation rules for `hm.dev.deploy(...)` - -- Exactly one of `image=` and `from_=` must be set; else `ValueError`. -- `port_mapping` keys are ints in `[1, 65535]`; values must be `hm.dev.port()` sentinels in v1 (pinned-int values are future). Wrong type → `ValueError` w/ pointer to `hm.dev.port()`. -- `volumes` keys are host paths (relative resolved against worktree root, absolute kept as-is); values are container paths. Container paths must start with `/`. -- `cmd` must be a sequence of strings; coerced to `tuple[str, ...]`. -- `env` values must be strings; non-strings rejected at decoration time (avoid `str(int)` surprises mid-run). -- Slug must match `^[a-z][a-z0-9-]{0,30}$` (Docker container name rules, lowercased). - -### `hm.dev.port()` sentinel - -Returns a singleton `_PortSentinel` instance. It is `==` to itself, has no public attributes, and its `__repr__` is ``. It is **only** valid as a value in `port_mapping`. Used anywhere else (env value, cmd arg, …) → `ValueError` at the point of use with a fix-directed message: - -``` -hm.dev.port() can only appear as a port_mapping value, not as an env value. - → use a fixed value here, or query the resolved port via - `hm dev port-of ` after `hm dev up`. -``` - -### Registry handoff (python → cli) - -`harmont.dev.dump_registry_json()` walks `.harmont/*.py`, runs decorators, filters the registry to local-driver deployments, and emits: - -```json -{ - "schema_version": "0", - "worktree": "/home/marko/myrepo", - "deployments": { - "db": { - "driver": "local", - "image": "postgres:16", - "from": null, - "cmd": ["postgres", "-c", "shared_buffers=128MB"], - "port_mapping": {"5432": "__hm_dev_port__"}, - "env": {"POSTGRES_PASSWORD": "dev"}, - "volumes": {}, - "workdir": null, - "deps": [] - }, - "api": { - "driver": "local", - "image": null, - "from": { "type": "step_chain", "pipeline_v0": { "version": "0", "steps": [/* ... */] } }, - "cmd": null, - "port_mapping": {"8000": "__hm_dev_port__"}, - "env": {"DATABASE_URL": "postgres://db:5432/app"}, - "volumes": {".": "/workspace"}, - "workdir": "/workspace", - "deps": ["db"] - }, - "prod-api": { "driver": "aws", "_unhandled": true } - } -} -``` - -Non-local-driver deployments are emitted with `"_unhandled": true` and opaque-otherwise so `hm dev ls` can show them. - -A separate `python -m harmont.dev --dump-registry` shim emits this to stdout. The CLI shells out to it. - ---- - -## §2 CLI surface - -### Container, network, and label scheme - -``` -session-id = 6 random hex chars, generated per `hm dev up` invocation -worktree-hash = sha1(canonical_path(git rev-parse --show-toplevel))[:10] -container = hm--- -network = hm-- -labels = harmont.worktree= - harmont.slug= - harmont.session= - harmont.driver=local -``` - -Multiple `hm dev up`s in the same worktree are allowed — each gets its own session and its own bridge network. Sessions never see each other's containers. - -If git is unavailable (no repo), worktree-hash falls back to sha1 of the absolute `cwd`. The CLI never refuses to run for lack of a git repo. - -### Subcommand tree - -``` -hm dev up [SLUG ...] foreground; blocks until SIGINT - --no-deps skip transitive deps - --rebuild force image rebuild on Step-chain deployments - -hm dev down [SLUG ...] sweep this worktree's sessions - --session sweep one specific session entirely - --all sweep system-wide (every harmont.driver=local container) - -hm dev ls list registered + running deployments - -hm dev logs [--follow] tail running container's logs from another terminal - --session disambiguate when ≥2 sessions hold the slug - -hm dev port-of print host port for live deployment (designed for $()) - --session disambiguate - -hm dev exec [-- CMD ...] one-shot exec into live container; default `sh -l` - --session disambiguate -``` - -### `hm dev up` UX - -``` -$ hm dev up -[hm] session 7a2f91. resolving deployments in .harmont/ -[hm] graph: db → api → web (3 deployments, 2 edges) -[hm] network hm-a1b2c3d4e5-7a2f91: created -[db] pulling postgres:16… -[db] ready ( hm-a1b2c3d4e5-db-7a2f91 | localhost:42173 → :5432 ) -[api] building from target api_image… -[api] ready ( hm-a1b2c3d4e5-api-7a2f91 | localhost:42174 → :8000 ) -[web] ready ( hm-a1b2c3d4e5-web-7a2f91 | localhost:42175 → :3000 ) -[hm] all up. Ctrl-C to tear down. Logs follow. -[db] 2026-05-21 12:00:00 UTC LOG: database system is ready to accept connections -[api] [info] listening on :8000 -[web] [HMR] connected -^C -[hm] tearing down… -[web] stopped -[api] stopped -[db] stopped -[hm] network hm-a1b2c3d4e5-7a2f91: removed -$ -``` - -**Log mux:** each slug gets a stable color from a fixed 6-ANSI palette (cycle by `hash(slug) % 6`). Prefix is `[slug] ` in slug's color; raw line follows uncolored. Slug-width padded to longest registered slug for vertical alignment. Honor `--no-color` and `NO_COLOR` env (per `clig.dev`). - -**Boot order:** topological. Each level boots in parallel; readiness is `docker inspect` reports `Running: true`. No log-grep or port-probe healthcheck in v1. - -### Ambiguity rule (port-of / logs / exec) - -When ≥2 live sessions in the current worktree hold the requested slug, error and enumerate: - -``` -$ hm dev port-of db 5432 -hm: slug `db` matches multiple live sessions in this worktree: - 7a2f91 started 12:00:14 localhost:42173 - c4d8e0 started 12:05:31 localhost:42891 -pass `--session ` or run `hm dev ls`. -exit 5 -``` - -No silent "pick latest" — explicit per PRINCIPLES § 5. - -### `hm dev ls` output - -``` -$ hm dev ls -SLUG DRIVER SESSION STATUS PORTS -db local 7a2f91 running localhost:42173 → :5432 -api local 7a2f91 running localhost:42174 → :8000 -db local c4d8e0 running localhost:42891 → :5432 -web local — registered (not running) -prod-api aws — registered (no local driver; use `hm aws up`) -``` - -Status comes from `docker inspect` filtered by `label=harmont.worktree=`. The `prod-api` row is rendered from the registry dump's `_unhandled: true` rows. - -### Exit codes (per PRINCIPLES § 4) - -``` -0 success -1 deployment-level failure (build chain failed, container failed to start) -2 usage error (clap parse) -3 auth (unused in v1, reserved) -4 slug known but not running (port-of / logs / exec on a stopped slug) -5 API/network error (docker daemon unreachable, slug unknown, ambiguous slug) -10 cancelled -130 SIGINT -``` - ---- - -## §3 Runtime & executor - -### Process model - -One `hm dev up` invocation is one Rust process, tokio-based, that: - -1. Shells out to `python -m harmont.dev --dump-registry` to get the deployment registry JSON. -2. Computes the boot plan (topo sort, optionally pruned by `--no-deps`). -3. Creates the per-session bridge network. -4. Boots deployments level-by-level (parallel within a level). -5. Multiplexes logs from all containers to stdout until SIGINT/SIGTERM. -6. Tears down: stop, remove containers, remove network. - -### Registry handoff - -``` -hm dev up ──exec──► python -m harmont.dev --dump-registry - │ - ▼ (stdout: JSON per §1 schema) - parse via serde -``` - -Rust types live in `crates/hm/src/commands/dev/registry.rs`: - -```rust -#[derive(Debug, Deserialize)] -struct DevRegistry { - schema_version: String, - worktree: String, - deployments: BTreeMap, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "driver")] -enum RegEntry { - #[serde(rename = "local")] Local(LocalSpec), - #[serde(other)] Unhandled, -} -``` - -`Unhandled` entries flow through to `hm dev ls` and are skipped by `hm dev up`. - -### Boot pipeline (per deployment) - -For each `D` in topo order: - -1. **Resolve image** - - `D.image` set → `docker_client.image_exists(tag)`; pull if absent. - - `D.from_step` set → run the embedded v0 IR pipeline via the existing `orchestrator::run_pipeline_local` codepath in a one-shot build container; on success `docker_client.commit_container(id, "hm-build--:")`. If a tag with that `` already exists and `--rebuild` not set, skip the build. -2. **Translate volumes** — host paths resolved against worktree root; emit bind-mounts `":[:ro]"`. -3. **Translate port mapping** — for each `{cport: __hm_dev_port__}`, emit `PortBinding{HostPort: ""}` for `cport/tcp` → daemon assigns ephemeral host port. -4. **Start container** via new `docker_client::start_service(spec)`: - - ```rust - pub struct ServiceSpec<'a> { - pub image: &'a str, - pub name: &'a str, - pub env: Vec, - pub cmd: Option>, - pub workdir: Option<&'a str>, - pub binds: Vec, - pub publish: Vec, - pub network: &'a str, - pub network_alias: &'a str, - pub labels: HashMap, - } - pub async fn start_service(&self, spec: ServiceSpec<'_>) -> Result; - ``` - -5. **Inspect for assigned ports** — `inspect_ports(container_id) -> HashMap` (container → host). Stored in an in-memory `Session`. -6. **Start log stream** — `bollard::container::logs(id, LogsOptions{follow: true, …})` → `mpsc::UnboundedSender` consumed by the log mux task. - -### Log mux - -`crates/hm/src/commands/dev/logmux.rs`: - -```rust -struct LogLine { slug: String, stream: Stream, bytes: Vec } -``` - -Per-slug `LinesReader` buffers partial chunks (docker streams may not be line-aligned), flushes on `\n`. Output format: `[] \n`. Stderr lines interleave with stdout in arrival order; v1 does not separate streams. - -### Concurrency model - -```rust -async fn up(args: UpArgs) -> Result<()> { - let reg = registry::dump(&ctx).await?; - let plan = topo::plan(®, &args)?; - let docker = DockerClient::connect()?; - let session_id = rand_hex(6); - let net = network::create(&docker, &ctx, &session_id).await?; - let mut session = Session::new(session_id, net.clone()); - - let (log_tx, log_rx) = mpsc::unbounded_channel(); - let mut sig = signal::ctrl_c_then_term(); - - for level in plan.levels() { - let mut joinset = JoinSet::new(); - for slug in level { - let spec = build_spec(®[slug], &ctx, &session, &net); - joinset.spawn(boot_one(docker.clone(), spec, log_tx.clone())); - } - while let Some(res) = joinset.join_next().await { - session.record(res??); - } - } - - tokio::spawn(logmux::run(log_rx, args.no_color)); - eprintln!("[hm] all up. Ctrl-C to tear down."); - - tokio::select! { - _ = sig.recv() => {} - _ = monitor_unexpected_exits(&docker, &session) => {} - } - - teardown(&docker, &session).await -} -``` - -`monitor_unexpected_exits` polls `docker inspect` for each container at 2s intervals; on a transition to non-running it logs `[slug] exited (code N)` and marks the entry. It does not unilaterally tear down — user might want to keep inspecting the live ones. - -### Build-chain reuse (`from_=Step`) - -Existing `orchestrator/` already executes v0 IR pipelines locally. The build path calls into the same codepath with two differences: - -| Step | `hm run` mode | `hm dev up` build mode | -|---|---|---| -| Final action | report run result | `commit_container` → tag `hm-build--:` | -| Stdout target | user-facing run UI | log mux as `[slug build]` lines | -| Cleanup | always rm one-shot container | rm one-shot container; tagged image survives | - -Single new function: - -```rust -pub async fn build_image_from_pipeline( - docker: &DockerClient, - pipeline_v0_ir: &PipelineV0, - image_tag: &str, - ctx: &Context, -) -> Result<()>; -``` - -### Field semantics summary - -| Field | Resolution | -|---|---| -| `cmd=["pg", "-c", "x"]` | Bollard `Config.cmd` — overrides image's CMD; entrypoint kept. | -| `env={"K": "V"}` | Bollard `Config.env: ["K=V", ...]`. Plain strings. Cross-deploy refs are decoration-time f-strings using `db.name`. | -| `volumes={".": "/workspace"}` | Host path resolved relative to worktree root; passed as `":[:ro]"`. RO opt-in via container-path suffix `":ro"`. | -| `workdir="/workspace"` | Bollard `Config.working_dir`. | -| `port_mapping={5432: hm.dev.port()}` | Bollard `HostConfig.port_bindings["5432/tcp"] = [{HostPort: ""}]` — daemon assigns ephemeral. | - ---- - -## §4 Lifecycle & signals - -### Boot - -1. Lock-free. Each session has unique container + network names. -2. Boot levels execute in topo order; failure of any one boot in a level fails the whole `up`. Partial teardown removes containers already started in this session plus the network. -3. Build-chain failures are reported as `[slug build] step X failed: ` and propagate. - -### Steady state - -- One log-mux task per session. -- One inspect-poller task at 2 s cadence detects unexpected container exits and logs them; does not tear down others. -- No automatic restarts. - -### Teardown - -- **First SIGINT/SIGTERM:** orderly teardown. - - For each container in reverse boot order: `docker stop` (10 s grace → SIGKILL), then `docker rm`. - - `docker network rm`. - - Exit 130 (SIGINT) or 143 (SIGTERM). -- **Second SIGINT during teardown:** hard exit (`process::exit(130)`); leftover containers + network are orphaned. User runs `hm dev down` to recover. -- **Process crashes (panic):** rust panic handler flushes a teardown call on best-effort; same recovery via `hm dev down`. - -### Orphan recovery - -`hm dev down` (no args) lists all containers labelled `harmont.worktree=` and removes them plus any associated networks. Idempotent. - ---- - -## §5 Error handling - -All errors follow PRINCIPLES § 5: point precisely, state observed, state fix. - -### Decoration-time (raised by `harmont-py`) - -``` -ValueError: hm.deploy slug must match ^[a-z][a-z0-9-]{0,30}$, got "API Service" - → rename the slug to a docker-safe form, e.g. "api-service" - -ValueError: hm.dev.port() can only appear as a port_mapping value, not as an env value. - → use a fixed value here, or query the resolved port via - `hm dev port-of ` after `hm dev up`. - -ValueError: hm.dev.deploy requires exactly one of `image=` or `from_=`, both were set. - → pick one. Use `image=` for a published image, `from_=` to build from a Step chain. - -RuntimeError: hm.deploy dep cycle: api -> db -> web -> api - → remove the cycle, or factor shared state into a target. - -ValueError: parameter `db` on @hm.deploy("api") has no type annotation. Every -parameter must carry a marker. - → add `db: hm.Dep[hm.Deployment]` (or `hm.Target[T]`) to inject it, - or give the parameter a default value. -``` - -### Runtime (raised by `hm dev`) - -``` -hm: docker daemon unreachable (Cannot connect to /var/run/docker.sock). - → start Docker Desktop, or run `sudo systemctl start docker`. -exit 5 - -hm: pull `postgres:16` failed: manifest unknown - → check the tag; `docker pull postgres:16` reproduces the failure. -exit 5 - -hm: build for `api` failed at step `cabal build all` (exit 1) - → see [api build] log lines above; run `hm run ` to debug. -exit 1 - -hm: slug `redis` not registered in this worktree's .harmont/ - → run `hm dev ls` to see registered slugs. -exit 5 - -hm: slug `db` matches multiple live sessions in this worktree: - 7a2f91 started 12:00:14 localhost:42173 - c4d8e0 started 12:05:31 localhost:42891 -pass `--session ` or run `hm dev ls`. -exit 5 - -hm: slug `db` registered but not running in this worktree. - → run `hm dev up db` first. -exit 4 -``` - ---- - -## §6 Testing - -### `harmont-py` unit tests (pytest) - -`tests/dev/` (new): - -- `test_decorator.py` — slug regex, duplicate-slug rejection, dep cycle detection, parameter-marker enforcement, fixture-injection produces a `Deployment` with `.name`. -- `test_port_sentinel.py` — `port()` outside `port_mapping` raises; sentinel equality; `repr`. -- `test_deploy_factory.py` — XOR of `image=` vs `from_=`; port_mapping value-type validation; env value-type validation; cmd coercion to tuple; volume path validation. -- `test_registry_dump.py` — golden JSON for the canonical db+api+web example; non-local entries marked `_unhandled`; deps list reflects fixture graph. -- `test_dump_cli.py` — `python -m harmont.dev --dump-registry` against a temp `.harmont/` writes the expected JSON to stdout. - -### `harmont-cli` unit tests (cargo test) - -`crates/hm/src/commands/dev/`: - -- `registry::tests` — serde round-trip of the v0 schema; unknown drivers parse as `Unhandled`. -- `topo::tests` — boot levels for db→api→web; `--no-deps` prunes correctly; cycle detection (defensive — should already be caught python-side). -- `logmux::tests` — partial-line buffering; ANSI prefix shape; `NO_COLOR` env strips colors. -- `port_of::tests` — single session returns plain int; multiple sessions return ambiguity error; missing slug exits 5; stopped slug exits 4. -- `naming::tests` — worktree-hash stable across invocations; session-id format `[0-9a-f]{6}`. - -### `harmont-cli` integration tests (cargo test, feature-gated) - -`crates/hm/tests/dev_integration.rs`, gated `--features docker-integration` and skipped when `DOCKER_HOST` unreachable: - -- Boot a single `postgres:16` deployment; assert `port-of` returns the inspected port; assert `psql -h localhost -p -U postgres -c 'select 1'` succeeds; teardown removes container + network. -- Boot db+api on bridge net; assert api container can `getent hosts db` and connect via `db:5432`. - -### Cross-repo "vibe" check (manual, documented in RELEASING.md) - -```bash -# In a temp dir -mkdir -p .harmont && cat > .harmont/pipelines.py <<'EOF' -import harmont as hm - -@hm.deploy("hello") -def hello(): - return hm.dev.deploy( - image="python:3.12-alpine", - cmd=["python", "-m", "http.server", "5678"], - port_mapping={5678: hm.dev.port()}, - ) -EOF -hm dev up hello & -sleep 2 -curl -fsS "http://localhost:$(hm dev port-of hello 5678)" | grep -q "Directory listing" -kill %1; wait -hm dev ls # should show nothing running -``` - ---- - -## Cross-repo file map - -### `harmont-py` - -``` -harmont/ - __init__.py # re-export hm.deploy, hm.Dep, hm.Deployment, hm.dev - _deploy.py # NEW: Deployment dataclass, top-level decorator, - # Dep[T] marker, dep-graph builder - _registry.py # MODIFY: add DEPLOYMENTS dict alongside REGISTRATIONS - dev/ - __init__.py # NEW: re-export hm.dev.deploy, hm.dev.port, - # hm.dev.LocalDeployment, hm.dev.dump_registry_json - __main__.py # NEW: `python -m harmont.dev --dump-registry` entry - _deployment.py # NEW: LocalDeployment dataclass + validation - _port.py # NEW: _PortSentinel + hm.dev.port() - _factory.py # NEW: hm.dev.deploy(...) factory - _registry_dump.py # NEW: dump_registry_json + JSON serializer - -tests/ - dev/ - __init__.py # NEW - test_decorator.py # NEW - test_port_sentinel.py # NEW - test_deploy_factory.py # NEW - test_registry_dump.py # NEW - test_dump_cli.py # NEW -``` - -### `harmont-cli` - -``` -crates/hm/src/ - cli.rs # MODIFY: add Dev(DevCommand) variant + subcommands - commands/ - mod.rs # MODIFY: register dev module - dev/ - mod.rs # NEW: subcommand dispatcher - registry.rs # NEW: invoke `python -m harmont.dev` + serde types - naming.rs # NEW: worktree-hash, session-id, container/network names - topo.rs # NEW: dep-graph topo sort + level grouping - network.rs # NEW: create/remove bridge network via bollard - logmux.rs # NEW: multi-source line-prefixed colored log stream - service_spec.rs # NEW: ServiceSpec + build_spec(reg, ctx, session, net) - up.rs # NEW: orchestrate boot + signal + teardown - down.rs # NEW: orphan sweep - ls.rs # NEW: registry walk + docker inspect merge - logs.rs # NEW: docker logs --follow shim - port_of.rs # NEW: inspect → host port lookup + ambiguity error - exec.rs # NEW: docker exec shim w/ TTY - orchestrator/ - docker_client.rs # MODIFY: add create_network, remove_network, - # attach_to_network, port_inspect, - # start_service, commit_container - mod.rs # MODIFY: pub fn build_image_from_pipeline - -crates/hm/tests/ - dev_integration.rs # NEW: feature-gated docker integration tests -``` - ---- - -## Open items deferred to follow-up specs - -- AWS / Fly / k8s drivers — when added, formalize a wire-format JSON IR for deployments and lift driver dispatch out of `hm dev` into a shared layer. -- Pipeline ↔ deployment auto-wiring (test pipelines that require deployments). -- Healthcheck DSL (`hm.dev.healthcheck(cmd=..., interval=...)`). -- Persistent named volumes. -- Daemon-mode `hm dev up --detach`. -- `hm dev up --watch` for hot-reload on `.harmont/*.py` changes.