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/CLAUDE.md b/CLAUDE.md index b492573..36848b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,59 @@ 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("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}, + ) +``` + +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 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. diff --git a/harmont/__init__.py b/harmont/__init__.py index e9d3e3f..ae07945 100644 --- a/harmont/__init__.py +++ b/harmont/__init__.py @@ -29,11 +29,12 @@ 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 -from ._typing import BaseImage, Target +from ._typing import BaseImage, Dep, Target from .cache import ( CacheCompose, CacheForever, @@ -134,12 +135,16 @@ def sh( "CacheOnChange", "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 new file mode 100644 index 0000000..acac143 --- /dev/null +++ b/harmont/_deploy.py @@ -0,0 +1,189 @@ +"""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 + +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`` 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]] = {} + + +_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 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 shared marker machinery: ``Target[T]``, + ``BaseImage[...]``, and ``Dep[T]`` (deployment-to-deployment refs). + + 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. 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 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_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= 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 + + +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/harmont/_deps.py b/harmont/_deps.py index a8238fd..a806c1f 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 _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: deployment {param.name!r} not found\n" + " → declare it with @hm.deploy() or rename the " + "parameter to match an existing deployment" + ) + raise TypeError(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..953db49 100644 --- a/harmont/_typing.py +++ b/harmont/_typing.py @@ -95,3 +95,24 @@ def BaseImage(image: str) -> _BaseImageMarker: # noqa: N802 — factory mimicki ) raise TypeError(msg) return _BaseImageMarker(image) + + +class _DepMarker: + """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__ = () + + 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/harmont/dev/__init__.py b/harmont/dev/__init__.py new file mode 100644 index 0000000..060c33e --- /dev/null +++ b/harmont/dev/__init__.py @@ -0,0 +1,19 @@ +"""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 port +from ._registry_dump import dump_registry_json + +__all__ = ["LocalDeployment", "deploy", "dump_registry_json", "port"] diff --git a/harmont/dev/__main__.py b/harmont/dev/__main__.py new file mode 100644 index 0000000..9366e61 --- /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: + msg = f"cannot load module from {path}" + raise RuntimeError(msg) + 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(): + sys.stderr.write( + f"hm: no .harmont/ directory in {root}\n" + " → create .harmont/ and add @hm.deploy-decorated functions\n" + ) + 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() is NoReturn (calls sys.exit(2)); execution stops here. + parser.error("nothing to do; pass --dump-registry") + + 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) + sys.stdout.write(dump_registry_json(worktree_root=root) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harmont/dev/_deployment.py b/harmont/dev/_deployment.py new file mode 100644 index 0000000..561a1cc --- /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/harmont/dev/_factory.py b/harmont/dev/_factory.py new file mode 100644 index 0000000..8d2e9f8 --- /dev/null +++ b/harmont/dev/_factory.py @@ -0,0 +1,153 @@ +"""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 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( + *, + 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) + + 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 TypeError(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 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 TypeError(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} ({type(hp).__name__})" + ) + 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 TypeError(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/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/harmont/dev/_registry_dump.py b/harmont/dev/_registry_dump.py new file mode 100644 index 0000000..8358c47 --- /dev/null +++ b/harmont/dev/_registry_dump.py @@ -0,0 +1,100 @@ +"""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 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 + +_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"), # noqa: S108 + 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/__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..ba961a6 --- /dev/null +++ b/tests/dev/conftest.py @@ -0,0 +1,20 @@ +"""Per-test reset of every registry the deploy DSL touches.""" +from __future__ import annotations + +import pytest + +from harmont._deploy import DEPLOYMENTS +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() + clear_registry() + clear_target_cache() + yield + DEPLOYMENTS.clear() + clear_registry() + clear_target_cache() diff --git a/tests/dev/test_canonical_example.py b/tests/dev/test_canonical_example.py new file mode 100644 index 0000000..f94ba04 --- /dev/null +++ b/tests/dev/test_canonical_example.py @@ -0,0 +1,47 @@ +"""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 +from typing import TYPE_CHECKING + +import harmont as hm + +if TYPE_CHECKING: + from pathlib import Path + + +def test_canonical_hello_greeter_dumps_expected_shape(tmp_path: Path) -> None: + @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=tmp_path) + out = json.loads(raw) + assert out["schema_version"] == "0" + 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 diff --git a/tests/dev/test_decorator.py b/tests/dev/test_decorator.py new file mode 100644 index 0000000..1036d21 --- /dev/null +++ b/tests/dev/test_decorator.py @@ -0,0 +1,98 @@ +"""@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(): + # 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()}) + + +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" diff --git a/tests/dev/test_dep_marker.py b/tests/dev/test_dep_marker.py new file mode 100644 index 0000000..21004ec --- /dev/null +++ b/tests/dev/test_dep_marker.py @@ -0,0 +1,42 @@ +"""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 +from harmont._typing import _DepMarker + + +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] # 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(): # noqa: N802 + # 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 + + # Matches the Target precedent: TypeError + "hm: 'name' not found". + with pytest.raises(TypeError, match="hm: deployment 'redis' not found"): + call_with_deps(consumer) diff --git a/tests/dev/test_deploy_factory.py b/tests/dev/test_deploy_factory.py new file mode 100644 index 0000000..3bc9905 --- /dev/null +++ b/tests/dev/test_deploy_factory.py @@ -0,0 +1,77 @@ +"""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(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(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"] + ) + assert d.cmd == ("postgres", "-c", "shared_buffers=128MB") + + +def test_cmd_rejects_non_string_elements(): + with pytest.raises(TypeError, match="cmd elements must be str"): + deploy(image="x", port_mapping={5432: port()}, cmd=["postgres", 5432]) # type: ignore[list-item] + + +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"} + + +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") diff --git a/tests/dev/test_dump_cli.py b/tests/dev/test_dump_cli.py new file mode 100644 index 0000000..f09386e --- /dev/null +++ b/tests/dev/test_dump_cli.py @@ -0,0 +1,60 @@ +"""`python -m harmont.dev --dump-registry` integration.""" +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap +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): + 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 diff --git a/tests/dev/test_local_deployment.py b/tests/dev/test_local_deployment.py new file mode 100644 index 0000000..0c24ef0 --- /dev/null +++ b/tests/dev/test_local_deployment.py @@ -0,0 +1,78 @@ +"""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(): + """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(AttributeError): + d.name = "other" # type: ignore[misc] # frozen + + +# --------------------------------------------------------------------------- +# Task 3: LocalDeployment tests +# --------------------------------------------------------------------------- + + +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) diff --git a/tests/dev/test_port_sentinel.py b/tests/dev/test_port_sentinel.py new file mode 100644 index 0000000..6ef994f --- /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. + assert {port(): 1}[port()] == 1 diff --git a/tests/dev/test_registry_dump.py b/tests/dev/test_registry_dump.py new file mode 100644 index 0000000..9aaa4af --- /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"))) # noqa: S108 + assert out["schema_version"] == "0" + assert out["worktree"] == "/tmp/wt" # noqa: S108 + 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"))) # noqa: S108 + 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"))) # noqa: S108 + 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"))) # noqa: S108 + 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"))) # noqa: S108 + assert out["deployments"]["prod-api"] == {"driver": "aws", "_unhandled": True} 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() 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()