Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/hm-dsl-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,22 @@ categories = ["command-line-utilities"]
path = "src/lib.rs"

[dependencies]
hm-pipeline-ir = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
derive_more = { workspace = true }
glob = "0.3"
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10"
include_dir = "0.7"
tempfile = "3"
tokio = { version = "1", features = ["process", "fs"] }
tracing = "0.1"
which = "7"

[dev-dependencies]
daggy = { workspace = true }
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
which = "7"
Expand Down
60 changes: 20 additions & 40 deletions crates/hm-dsl-engine/harmont-py/harmont/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,16 @@
Step.fork(label=None) -> Step
wait(*, continue_on_failure=False) -> Step

pipeline(leaves, *, env=None) -> dict (v0 IR)
pipeline_to_json(p, **kw) -> str

@pipeline(slug, ..., triggers=[...], allow_manual=True) -> decorator
push(branch=..., tag=...) -> PushTrigger
pull_request(branches=..., types=...) -> PullRequestTrigger
dump_registry_json() -> str (HAR-9 envelope)

Cache helpers: `ttl`, `on_change`, `forever`, `compose`.

``hm.pipeline`` is polymorphic. When called with a list of ``Step``
objects it builds a v0 IR dict (the factory). When called with no
positionals or a string slug it returns a decorator that registers a
function as a CI pipeline (HAR-9).
``hm.pipeline`` is a decorator that registers a function as a CI pipeline
(HAR-9). Lowering to the v0 IR happens on the Rust side after the envelope
is emitted by ``dump_registry_json``.
"""

from __future__ import annotations
Expand All @@ -36,8 +32,6 @@
from ._envelope import dump_registry_json
from ._go import go
from ._js import JsProject, js, ts
from ._pipeline import pipeline as _pipeline_factory
from ._pipeline import pipeline_to_json
from ._python import python
from ._rust import RustProject, rust
from ._step import Step, scratch, wait
Expand All @@ -62,46 +56,33 @@


def pipeline(*args: Any, **kwargs: Any) -> Any:
"""Build a v0 IR dict or register a pipeline function.

This function is polymorphic based on the type of its positional arguments.

Factory form — first positional is a list/tuple of ``Step``s:

pipeline([step1, step2, ...], env=None) -> dict

Decorator form — no positionals or a string slug:
"""Register a function as a CI pipeline (decorator form).

@pipeline(slug=None, *, name=None, triggers=(), allow_manual=True,
env=None)
env=None, timeout=None)
def my_pipeline() -> Step: ...

The discriminant is the type of the first positional argument:
a list or tuple routes to the factory path; anything else
(including no positionals) routes to the decorator path.
The decorated function returns the terminal ``Step`` (or a tuple/list of
leaves) of each branch. Lowering to the v0 IR happens on the Rust side
after ``dump_registry_json`` emits the envelope.

Returns:
A v0 IR ``dict`` in factory form, or a decorator in decorator form.
A decorator that registers the wrapped function and returns it.

Raises:
TypeError: When called with the legacy variadic ``Step`` form
(``pipeline(step)`` / ``pipeline(a, b)``). The factory now takes
a single list of leaves.
TypeError: When called with ``Step`` objects or a list of leaves —
the pre-CLI-45 factory form (``pipeline([step, ...])``) is gone;
annotate a function with ``@pipeline`` and return the leaves.
"""
if args and isinstance(args[0], (list, tuple)):
return _pipeline_factory(args[0], **kwargs)
# Legacy form: leaves passed as positional Step args (pre-CLI-9
# `pipeline(step)` / `pipeline(a, b)`). Without this guard the call would
# fall through to the decorator and fail far downstream with a cryptic
# AttributeError. Fail fast with the migration hint instead.
if args and all(isinstance(a, Step) for a in args):
if args and (
isinstance(args[0], (list, tuple)) or all(isinstance(a, Step) for a in args)
):
msg = (
"hm.pipeline() takes a single list of leaves, not variadic Step "
"arguments\n"
f" observed: {len(args)} positional Step "
f"argument{'s' if len(args) != 1 else ''}\n"
" → wrap the leaves in a list, e.g. "
"hm.pipeline([step]) or hm.pipeline([a, b])"
"hm.pipeline is a decorator, not a factory\n"
" → annotate a function and return the leaves, e.g.\n"
" @hm.pipeline('ci')\n"
" def ci():\n"
" return [step_a, step_b]"
)
raise TypeError(msg)
return _decorator.pipeline(*args, **kwargs)
Expand Down Expand Up @@ -322,7 +303,6 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]:
"js",
"on_change",
"pipeline",
"pipeline_to_json",
"pr",
"pull_request",
"push",
Expand Down
3 changes: 2 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_cmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,8 @@ def __call__(
Examples:
>>> import harmont as hm
>>> proj = hm.cmake(path=".", compiler="gcc-14")
>>> hm.pipeline([proj.build(), proj.test()])
>>> proj.build()
>>> proj.test()
"""
tc = _make_toolchain(
compiler=compiler,
Expand Down
6 changes: 4 additions & 2 deletions crates/hm-dsl-engine/harmont-py/harmont/_elixir.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ def setup(
Examples:
>>> import harmont as hm
>>> proj = hm.elixir(path="elixir").setup("mix proto.gen")
>>> hm.pipeline([proj.compile(), proj.test()])
>>> proj.compile()
>>> proj.test()
"""
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)

Expand Down Expand Up @@ -266,7 +267,8 @@ def __call__(
Examples:
>>> import harmont as hm
>>> proj = hm.elixir(elixir_version="1.18.3")
>>> hm.pipeline([proj.compile(), proj.test()])
>>> proj.compile()
>>> proj.test()
"""
return _make_elixir(
path=path,
Expand Down
64 changes: 13 additions & 51 deletions crates/hm-dsl-engine/harmont-py/harmont/_envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,93 +3,55 @@
See docs/superpowers/specs/2026-05-10-har-9-imperfect-dsl-design.md
§ "The envelope" for the wire format.

Each registered pipeline carries its resolved v0 IR as a nested
``definition`` object. Consumers (api, cli) read that directly — no
intermediate Scheme stage exists since HAR-16.
Each registered pipeline carries its raw step chain as a nested
``step_chain`` object. Rust (`crates/hm-dsl-engine/src/step_chain.rs`)
lowers that into the v0 IR — env layering, key resolution, and the
default-image stamp all live on the Rust side.
"""

from __future__ import annotations

import json
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any

from ._pipeline import pipeline as _assemble
from ._registry import REGISTRATIONS, PipelineRegistration
from ._serialize import serialize_step_chain
from ._target import clear_target_memo
from ._unwrap import as_leaves
from .keygen import resolve_pipeline_keys

if TYPE_CHECKING:
from collections.abc import Mapping


def _render_one(
reg: PipelineRegistration,
*,
pipeline_org: str,
now: int,
base_path: Path,
env: Mapping[str, str],
) -> dict[str, Any]:
def _render_one(reg: PipelineRegistration) -> dict[str, Any]:
raw = reg.fn()
try:
leaves = as_leaves(raw)
except TypeError as e:
msg = f"pipeline {reg.slug!r}: invalid return value\n → {e}"
raise TypeError(msg) from e
ir = _assemble(leaves, env=reg.env, timeout=reg.timeout)
resolve_pipeline_keys(
ir.get("graph", {}),
pipeline_org=pipeline_org,
pipeline_slug=reg.slug,
now=now,
base_path=base_path,
env=env,
)
step_chain = serialize_step_chain(leaves, env=reg.env, timeout=reg.timeout)
return {
"slug": reg.slug,
"name": reg.name,
"allow_manual": reg.allow_manual,
"triggers": [t.to_dict() for t in reg.triggers],
"definition": ir,
"step_chain": step_chain,
}


def dump_registry_json(
*,
pipeline_org: str | None = None,
now: int | None = None,
base_path: Path | None = None,
env: Mapping[str, str] | None = None,
) -> str:
def dump_registry_json() -> str:
"""Emit the schema_version=1 envelope JSON.

Defaults mirror ``pipeline_to_json``:
``pipeline_org`` <- ``env["HM_PIPELINE_ORG"]`` or ``"default"``
``now`` <- ``int(time.time())``
``base_path`` <- ``Path.cwd()`` (resolves ``on_change`` cache paths)
``env`` <- ``os.environ``
Per-pipeline slug is read from each registration.
Each pipeline's raw step chain is serialized; Rust performs the
lowering (env layering, cache-key resolution, default-image stamp).

The target memoization cache is cleared at the start of each render
so per-pipeline target invocations dedup within a single render but
don't leak across renders. The named-target registry is left intact
so pipeline fixture-style params can resolve their dependencies.
"""
clear_target_memo()
env_map: Mapping[str, str] = env if env is not None else os.environ
org = pipeline_org if pipeline_org is not None else env_map.get("HM_PIPELINE_ORG", "default")
render_now = now if now is not None else int(time.time())
bp = base_path if base_path is not None else Path.cwd()
return json.dumps(
{
"schema_version": "1",
"pipelines": [
_render_one(reg, pipeline_org=org, now=render_now, base_path=bp, env=env_map)
for reg in REGISTRATIONS
],
"pipelines": [_render_one(reg) for reg in REGISTRATIONS],
}
)
3 changes: 2 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_go.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ def __call__(
Examples:
>>> import harmont as hm
>>> tc = hm.go(version="1.23.2")
>>> hm.pipeline([tc.test(), tc.vet()])
>>> tc.test()
>>> tc.vet()
"""
return _make_go(path=path, version=version, image=image, base=base)

Expand Down
3 changes: 2 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,8 @@ def project(
Examples:
>>> import harmont as hm
>>> proj = hm.js.project(path="web", runtime="bun")
>>> hm.pipeline([proj.run("test"), proj.run("lint")])
>>> proj.run("test")
>>> proj.run("lint")
"""
return _make_js(path=path, pm=pm, runtime=runtime, version=version, image=image, base=base)

Expand Down
Loading
Loading