harmont 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
harmont/__init__.py ADDED
@@ -0,0 +1,168 @@
1
+ """harmont — chain-style Python DSL for Harmont CI pipelines.
2
+
3
+ The whole public surface:
4
+
5
+ scratch() -> Step (root)
6
+ sh(cmd, **kw) -> Step (== scratch().sh(cmd, **kw))
7
+ Step.sh(cmd, **kw) -> Step
8
+ Step.fork(label=None) -> Step
9
+ wait(*, continue_on_failure=False) -> Step
10
+
11
+ pipeline(*leaves, env=None, default_image=None) -> dict (v0 IR)
12
+ pipeline_to_json(p, **kw) -> str
13
+
14
+ @pipeline(slug, ..., triggers=[...], allow_manual=True) -> decorator
15
+ push(branch=..., tag=...) -> PushTrigger
16
+ pull_request(branches=..., types=...) -> PullRequestTrigger
17
+ schedule(cron=...) -> ScheduleTrigger
18
+ dump_registry_json() -> str (HAR-9 envelope)
19
+
20
+ Cache helpers: ttl, on_change, forever, compose.
21
+
22
+ ``hm.pipeline`` is polymorphic. When called with positional ``Step``
23
+ arguments it builds a v0 IR dict (the factory). When called with no
24
+ positionals or a string slug it returns a decorator that registers a
25
+ function as a CI pipeline (HAR-9).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ from . import _decorator
33
+ from ._envelope import dump_registry_json
34
+ from ._step import Step, scratch, wait
35
+ from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests
36
+ from ._typing import BaseImage, Target
37
+ from .cache import (
38
+ CacheCompose,
39
+ CacheForever,
40
+ CacheNone,
41
+ CacheOnChange,
42
+ CachePolicy,
43
+ CacheTTL,
44
+ )
45
+ from .cmake import cmake
46
+ from .composer import composer
47
+ from .dotnet import dotnet
48
+ from .elm import elm
49
+ from .go import go
50
+ from .gradle import gradle
51
+ from .haskell import haskell
52
+ from .npm import npm
53
+ from .ocaml import ocaml
54
+ from .perl import perl
55
+ from .pipeline import pipeline as _pipeline_factory
56
+ from .pipeline import pipeline_to_json
57
+ from .python import python
58
+ from .ruby import ruby
59
+ from .rust import rust
60
+ from .triggers import pull_request, push, schedule
61
+ from .types import Pipeline
62
+ from .zig import zig
63
+
64
+ if TYPE_CHECKING:
65
+ from datetime import timedelta
66
+
67
+
68
+ def pipeline(*args: Any, **kwargs: Any) -> Any:
69
+ """Polymorphic entry point.
70
+
71
+ - ``pipeline(*leaves, env=..., default_image=...)`` — every
72
+ positional arg is a :class:`Step`; returns the v0 IR dict (the
73
+ factory).
74
+ - ``pipeline(slug=None, *, name=..., triggers=..., allow_manual=...,
75
+ env=..., default_image=...)`` — no positionals or a string slug;
76
+ returns a decorator that registers the wrapped function in the
77
+ module-level :data:`~harmont._registry.REGISTRATIONS` table
78
+ (HAR-9).
79
+
80
+ The discriminant is the *type* of the positional arguments: any
81
+ non-Step positional (including a string slug, or no positional at
82
+ all) routes to the decorator path.
83
+ """
84
+ if args and all(isinstance(a, Step) for a in args):
85
+ return _pipeline_factory(*args, **kwargs)
86
+ return _decorator.pipeline(*args, **kwargs)
87
+
88
+
89
+ def ttl(duration: timedelta) -> CacheTTL:
90
+ return CacheTTL(duration=duration)
91
+
92
+
93
+ def on_change(*paths: str) -> CacheOnChange:
94
+ return CacheOnChange(paths=tuple(paths))
95
+
96
+
97
+ def forever(env_keys: tuple[str, ...] = ()) -> CacheForever:
98
+ return CacheForever(env_keys=env_keys)
99
+
100
+
101
+ def compose(*policies: CachePolicy) -> CacheCompose:
102
+ return CacheCompose(policies=tuple(policies))
103
+
104
+
105
+ def sh(
106
+ cmd: str,
107
+ *,
108
+ cwd: str | None = None,
109
+ label: str | None = None,
110
+ cache: CachePolicy | None = None,
111
+ env: dict[str, str] | None = None,
112
+ timeout_seconds: int | None = None,
113
+ image: str | None = None,
114
+ key: str | None = None,
115
+ ) -> Step:
116
+ """Shorthand for ``scratch().sh(cmd, ...)`` — start a chain in one call."""
117
+ return scratch().sh(
118
+ cmd,
119
+ cwd=cwd,
120
+ label=label,
121
+ cache=cache,
122
+ env=env,
123
+ timeout_seconds=timeout_seconds,
124
+ image=image,
125
+ key=key,
126
+ )
127
+
128
+
129
+ __all__ = [
130
+ "BaseImage",
131
+ "CacheCompose",
132
+ "CacheForever",
133
+ "CacheNone",
134
+ "CacheOnChange",
135
+ "CachePolicy",
136
+ "CacheTTL",
137
+ "Pipeline",
138
+ "Step",
139
+ "Target",
140
+ "cmake",
141
+ "compose",
142
+ "composer",
143
+ "dotnet",
144
+ "dump_registry_json",
145
+ "elm",
146
+ "forever",
147
+ "go",
148
+ "gradle",
149
+ "haskell",
150
+ "npm",
151
+ "ocaml",
152
+ "on_change",
153
+ "perl",
154
+ "pipeline",
155
+ "pipeline_to_json",
156
+ "pull_request",
157
+ "push",
158
+ "python",
159
+ "ruby",
160
+ "rust",
161
+ "schedule",
162
+ "scratch",
163
+ "sh",
164
+ "target",
165
+ "ttl",
166
+ "wait",
167
+ "zig",
168
+ ]
harmont/_decorator.py ADDED
@@ -0,0 +1,68 @@
1
+ """@hm.pipeline decorator — see docs/superpowers/specs/2026-05-10-har-9-imperfect-dsl-design.md."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from functools import wraps
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ._deps import call_with_deps, validate_target_signature
9
+ from ._registry import PipelineRegistration, register
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from .triggers import Trigger
15
+
16
+ _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
17
+
18
+
19
+ def _validate_slug(slug: str) -> None:
20
+ if not _SLUG_RE.match(slug):
21
+ msg = (
22
+ f"invalid pipeline slug {slug!r}\n"
23
+ f" → use lowercase letters, digits, and '-', "
24
+ f"start with a letter, max 64 chars"
25
+ )
26
+ raise ValueError(msg)
27
+
28
+
29
+ def pipeline(
30
+ slug: str | None = None,
31
+ *,
32
+ name: str | None = None,
33
+ triggers: tuple[Trigger, ...] | list[Trigger] = (),
34
+ allow_manual: bool = True,
35
+ env: dict[str, str] | None = None,
36
+ default_image: str | None = None,
37
+ ) -> Callable[[Callable[..., Any]], Callable[[], Any]]:
38
+ """Register a function as a CI pipeline.
39
+
40
+ The wrapped function returns a :class:`Step`, a tuple of leaves
41
+ (:data:`Pipeline`), or any toolchain wrapper that
42
+ :func:`harmont._unwrap.as_leaves` can coerce. The function may
43
+ declare dependencies as parameters (pytest-style); each parameter
44
+ name is resolved against the global target registry.
45
+ """
46
+ def decorator(fn: Callable[..., Any]) -> Callable[[], Any]:
47
+ validate_target_signature(fn)
48
+ resolved = slug if slug is not None else fn.__name__
49
+ _validate_slug(resolved)
50
+
51
+ @wraps(fn)
52
+ def wrapper() -> Any:
53
+ return call_with_deps(fn)
54
+
55
+ register(
56
+ PipelineRegistration(
57
+ slug=resolved,
58
+ name=name if name is not None else resolved,
59
+ triggers=tuple(triggers),
60
+ allow_manual=allow_manual,
61
+ env=env,
62
+ default_image=default_image,
63
+ fn=wrapper,
64
+ )
65
+ )
66
+ return wrapper
67
+
68
+ return decorator
harmont/_deps.py ADDED
@@ -0,0 +1,188 @@
1
+ """Shared dependency resolution for @hm.target and @hm.pipeline (HAR-28).
2
+
3
+ Strict-marker model:
4
+ - ``Target[T]`` — resolve by parameter name from the global
5
+ target registry; raise if not found.
6
+ - ``BaseImage["X"]`` — inject a scratch-rooted ``Step(image=X)``.
7
+ - plain param with default — bind the default value.
8
+ - anything else — raise at decoration time via
9
+ :func:`validate_target_signature`.
10
+
11
+ Cycle detection uses a module-level "currently resolving" stack keyed
12
+ by function name; the dump_registry_json render clears it at the
13
+ start of every render along with the target memoization cache.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import typing
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from ._step import Step
23
+ from ._typing import _TARGET_MARKER, _BaseImageMarker
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+
28
+
29
+ _TARGETS_BY_NAME: dict[str, Callable[[], Any]] = {}
30
+ _RESOLVING: list[str] = []
31
+
32
+
33
+ def register_named_target(name: str, fn: Callable[[], Any]) -> None:
34
+ """Register a named target. Raises on duplicate name."""
35
+ if name in _TARGETS_BY_NAME:
36
+ msg = (
37
+ f"hm: duplicate target name {name!r}\n"
38
+ " → each @hm.target must have a unique name; pass "
39
+ 'name="..." to disambiguate'
40
+ )
41
+ raise ValueError(msg)
42
+ _TARGETS_BY_NAME[name] = fn
43
+
44
+
45
+ def clear_target_names() -> None:
46
+ """Reset the name registry and cycle-detection stack. Used by tests
47
+ and `clear_target_cache()` (the full reset used at test boundaries)."""
48
+ _TARGETS_BY_NAME.clear()
49
+ _RESOLVING.clear()
50
+
51
+
52
+ def _param_kind_error(param: inspect.Parameter) -> str | None:
53
+ """Return a fix-directed error message if `param` has a forbidden kind."""
54
+ kind = param.kind
55
+ if kind == inspect.Parameter.VAR_POSITIONAL:
56
+ return (
57
+ "hm: target functions cannot take *args\n"
58
+ " → declare each dependency as an explicit named parameter"
59
+ )
60
+ if kind == inspect.Parameter.VAR_KEYWORD:
61
+ return (
62
+ "hm: target functions cannot take **kwargs\n"
63
+ " → declare each dependency as an explicit named parameter"
64
+ )
65
+ if kind == inspect.Parameter.POSITIONAL_ONLY:
66
+ return (
67
+ f"hm: target functions cannot have positional-only "
68
+ f"parameters (got {param.name!r})\n"
69
+ " → remove the '/' marker; parameters must be name-resolvable"
70
+ )
71
+ return None
72
+
73
+
74
+ def _marker_for(annotation: Any) -> object | None:
75
+ """Inspect an `Annotated[T, ...]` annotation and return the
76
+ hm-specific marker (a `_TargetMarker` or `_BaseImageMarker`) if
77
+ present, else None."""
78
+ if typing.get_origin(annotation) is None:
79
+ return None
80
+ metadata = typing.get_args(annotation)[1:]
81
+ for meta in metadata:
82
+ if meta is _TARGET_MARKER:
83
+ return _TARGET_MARKER # type: ignore[no-any-return]
84
+ if isinstance(meta, _BaseImageMarker):
85
+ return meta
86
+ return None
87
+
88
+
89
+ def _safe_get_type_hints(fn: Callable[..., Any]) -> dict[str, Any]:
90
+ """`typing.get_type_hints(fn, include_extras=True)` but tolerant of
91
+ forward references that fail to resolve — fall back to the raw
92
+ `__annotations__` dict so markers still surface."""
93
+ try:
94
+ return typing.get_type_hints(fn, include_extras=True)
95
+ except Exception: # intentionally broad; fallback path
96
+ return dict(getattr(fn, "__annotations__", {}))
97
+
98
+
99
+ def validate_target_signature(fn: Callable[..., Any]) -> None:
100
+ """Decoration-time validation. Raise TypeError on any of:
101
+
102
+ - `*args` / `**kwargs` / positional-only parameter.
103
+ - Parameter with no marker and no default value.
104
+
105
+ A parameter with an `hm.Target[T]` or `hm.BaseImage["X"]` marker
106
+ in its annotation is always valid. A parameter with neither
107
+ marker but a default value is allowed (the default is used).
108
+ """
109
+ sig = inspect.signature(fn)
110
+ hints = _safe_get_type_hints(fn)
111
+ for param in sig.parameters.values():
112
+ kind_err = _param_kind_error(param)
113
+ if kind_err is not None:
114
+ raise TypeError(kind_err)
115
+ annotation = hints.get(param.name)
116
+ if _marker_for(annotation) is not None:
117
+ continue
118
+ if param.default is not inspect.Parameter.empty:
119
+ continue
120
+ msg = (
121
+ f"hm: parameter {param.name!r} has no marker and no default\n"
122
+ " → annotate with Target[T] (target dep) or "
123
+ 'BaseImage["..."] (scratch image), or give it a default'
124
+ )
125
+ raise TypeError(msg)
126
+
127
+
128
+ def resolve_deps(fn: Callable[..., Any]) -> dict[str, Any]:
129
+ """Walk ``fn``'s signature and produce the kwargs to invoke it.
130
+
131
+ Marker dispatch per parameter:
132
+ - `Target[T]` → look up param name in `_TARGETS_BY_NAME`;
133
+ raise if not found.
134
+ - `BaseImage["X"]` → inject `Step(image="X")` (a scratch root).
135
+ - no marker, default → bind the default value.
136
+ - no marker, no default → raise (caught earlier by
137
+ `validate_target_signature` for well-formed targets).
138
+ """
139
+ sig = inspect.signature(fn)
140
+ hints = _safe_get_type_hints(fn)
141
+ kwargs: dict[str, Any] = {}
142
+ for param in sig.parameters.values():
143
+ kind_err = _param_kind_error(param)
144
+ if kind_err is not None:
145
+ raise TypeError(kind_err)
146
+ annotation = hints.get(param.name)
147
+ marker = _marker_for(annotation)
148
+ if marker is _TARGET_MARKER:
149
+ if param.name not in _TARGETS_BY_NAME:
150
+ msg = (
151
+ f"hm: target {param.name!r} not found\n"
152
+ " → declare it with @hm.target() or rename the "
153
+ "parameter to match an existing target"
154
+ )
155
+ raise TypeError(msg)
156
+ kwargs[param.name] = _TARGETS_BY_NAME[param.name]()
157
+ continue
158
+ if isinstance(marker, _BaseImageMarker):
159
+ kwargs[param.name] = Step(image=marker.image)
160
+ continue
161
+ if param.default is not inspect.Parameter.empty:
162
+ kwargs[param.name] = param.default
163
+ continue
164
+ msg = (
165
+ f"hm: parameter {param.name!r} has no marker and no default\n"
166
+ ' → annotate with Target[T] or BaseImage["..."], or '
167
+ "give it a default"
168
+ )
169
+ raise TypeError(msg)
170
+ return kwargs
171
+
172
+
173
+ def call_with_deps(fn: Callable[..., Any]) -> Any:
174
+ """Resolve ``fn``'s parameters and call it. Detects cycles."""
175
+ name = fn.__name__
176
+ if name in _RESOLVING:
177
+ cycle = " → ".join([*_RESOLVING, name])
178
+ msg = (
179
+ f"hm: dependency cycle detected\n"
180
+ f" → {cycle}\n"
181
+ " fix: break the cycle, or extract a shared root target"
182
+ )
183
+ raise RuntimeError(msg)
184
+ _RESOLVING.append(name)
185
+ try:
186
+ return fn(**resolve_deps(fn))
187
+ finally:
188
+ _RESOLVING.pop()
harmont/_envelope.py ADDED
@@ -0,0 +1,100 @@
1
+ """Envelope renderer — produces the schema_version=1 JSON document.
2
+
3
+ See docs/superpowers/specs/2026-05-10-har-9-imperfect-dsl-design.md
4
+ § "The envelope" for the wire format.
5
+
6
+ Each registered pipeline carries its resolved v0 IR as a nested
7
+ ``definition`` object. Consumers (api, cli) read that directly — no
8
+ intermediate Scheme stage exists since HAR-16.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from ._registry import REGISTRATIONS, PipelineRegistration
20
+ from ._target import clear_target_memo
21
+ from ._unwrap import as_leaves
22
+ from .keygen import resolve_pipeline_keys
23
+ from .pipeline import pipeline as _assemble
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping
27
+
28
+
29
+ def _render_one(
30
+ reg: PipelineRegistration,
31
+ *,
32
+ pipeline_org: str,
33
+ now: int,
34
+ base_path: Path,
35
+ env: Mapping[str, str],
36
+ ) -> dict[str, Any]:
37
+ raw = reg.fn()
38
+ try:
39
+ leaves = as_leaves(raw)
40
+ except TypeError as e:
41
+ msg = (
42
+ f"pipeline {reg.slug!r}: invalid return value\n"
43
+ f" → {e}"
44
+ )
45
+ raise TypeError(msg) from e
46
+ ir = _assemble(*leaves, env=reg.env, default_image=reg.default_image)
47
+ resolve_pipeline_keys(
48
+ ir.get("steps", []),
49
+ pipeline_org=pipeline_org,
50
+ pipeline_slug=reg.slug,
51
+ now=now,
52
+ base_path=base_path,
53
+ env=env,
54
+ )
55
+ return {
56
+ "slug": reg.slug,
57
+ "name": reg.name,
58
+ "allow_manual": reg.allow_manual,
59
+ "triggers": [t.to_dict() for t in reg.triggers],
60
+ "definition": ir,
61
+ }
62
+
63
+
64
+ def dump_registry_json(
65
+ *,
66
+ pipeline_org: str | None = None,
67
+ now: int | None = None,
68
+ base_path: Path | None = None,
69
+ env: Mapping[str, str] | None = None,
70
+ ) -> str:
71
+ """Emit the schema_version=1 envelope JSON.
72
+
73
+ Defaults mirror ``pipeline_to_json``:
74
+ ``pipeline_org`` <- ``env["HARMONT_PIPELINE_ORG"]`` or ``"default"``
75
+ ``now`` <- ``int(time.time())``
76
+ ``base_path`` <- ``Path.cwd()`` (resolves ``on_change`` cache paths)
77
+ ``env`` <- ``os.environ``
78
+ Per-pipeline slug is read from each registration.
79
+
80
+ The target memoization cache is cleared at the start of each render
81
+ so per-pipeline target invocations dedup within a single render but
82
+ don't leak across renders. The named-target registry is left intact
83
+ so pipeline fixture-style params can resolve their dependencies.
84
+ """
85
+ clear_target_memo()
86
+ env_map: Mapping[str, str] = env if env is not None else os.environ
87
+ org = pipeline_org if pipeline_org is not None else env_map.get(
88
+ "HARMONT_PIPELINE_ORG", "default"
89
+ )
90
+ render_now = now if now is not None else int(time.time())
91
+ bp = base_path if base_path is not None else Path.cwd()
92
+ return json.dumps(
93
+ {
94
+ "schema_version": "1",
95
+ "pipelines": [
96
+ _render_one(reg, pipeline_org=org, now=render_now, base_path=bp, env=env_map)
97
+ for reg in REGISTRATIONS
98
+ ],
99
+ }
100
+ )
harmont/_keys.py ADDED
@@ -0,0 +1,121 @@
1
+ """Key derivation for chain-DSL steps.
2
+
3
+ Order of precedence per the design doc:
4
+ 1. explicit `key=` override on .sh()
5
+ 2. slugified label (when unique within the pipeline)
6
+ 3. stable 12-char hash of (parent_resolved_key, cmd, position)
7
+
8
+ Collision policy: when two steps' label-slugs collide and neither
9
+ claimed the slug via explicit `key=`, both fall back to hash. An
10
+ explicit override always wins, even if it would collide with another
11
+ step's natural slug.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import re
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable
22
+
23
+ from ._step import Step
24
+
25
+ _EMOJI_SHORTCODE_RE = re.compile(r":[a-z0-9_+-]+:")
26
+ _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
27
+
28
+
29
+ def slugify_label(label: str) -> str:
30
+ """Lowercase, strip ``:emoji_codes:``, replace non-alnum runs with ``-``,
31
+ trim leading/trailing dashes.
32
+
33
+ Slugs are ASCII-only by policy (matches Buildkite). Non-ASCII
34
+ letters are treated as separators: ``"Café Build"`` slugs to
35
+ ``"caf-build"`` and ``"构建"`` slugs to ``""``. Labels that reduce
36
+ to the empty string fall back to a hash key in ``resolve_keys``;
37
+ the user's label is preserved on the step's ``label`` field for
38
+ display, only the cross-reference key is hash-based.
39
+ """
40
+ s = label.lower()
41
+ s = _EMOJI_SHORTCODE_RE.sub(" ", s)
42
+ s = _NON_ALNUM_RE.sub("-", s)
43
+ return s.strip("-")
44
+
45
+
46
+ def hash_key(parent_key: str, cmd: str, position: int) -> str:
47
+ """Stable 12-char SHA-256 prefix over (parent_key, cmd, position).
48
+
49
+ Used as the fallback key when no usable slug is available."""
50
+ h = hashlib.sha256()
51
+ h.update(parent_key.encode("utf-8"))
52
+ h.update(b"\x00")
53
+ h.update(cmd.encode("utf-8"))
54
+ h.update(b"\x00")
55
+ h.update(str(position).encode("utf-8"))
56
+ return h.hexdigest()[:12]
57
+
58
+
59
+ def resolve_keys(steps: Iterable[Step]) -> dict[int, str]:
60
+ """Resolve each Step's key. Returns ``{id(step): key}``.
61
+
62
+ The ``id()`` indexing is deliberate: two structurally-equal Steps
63
+ that arose from independent fork branches must keep distinct keys,
64
+ and frozen-dataclass equality would conflate them.
65
+ """
66
+ steps_list = list(steps)
67
+
68
+ overrides: dict[int, str] = {}
69
+ # Natural slug per step (computed for every labeled step, even
70
+ # those with explicit overrides — see slug_counts below).
71
+ natural_slugs: dict[int, str] = {}
72
+ for s in steps_list:
73
+ if s.key_override is not None:
74
+ overrides[id(s)] = s.key_override
75
+ if s.label is not None:
76
+ slug = slugify_label(s.label)
77
+ if slug:
78
+ natural_slugs[id(s)] = slug
79
+
80
+ # Reserve every override; any natural slug that matches a reserved
81
+ # override is a collision for the slug claimant.
82
+ reserved = set(overrides.values())
83
+
84
+ # Detect slug collisions across every labeled step — including those
85
+ # with explicit overrides. An override-bearing step still "claims"
86
+ # its natural slug for collision purposes, so a peer with the same
87
+ # label can't quietly take it.
88
+ slug_counts: dict[str, int] = {}
89
+ for slug in natural_slugs.values():
90
+ slug_counts[slug] = slug_counts.get(slug, 0) + 1
91
+
92
+ # The slug pool that non-override steps may draw from: only steps
93
+ # without a `key=` override are eligible to receive their slug.
94
+ label_slugs: dict[int, str] = {
95
+ sid: slug for sid, slug in natural_slugs.items() if sid not in overrides
96
+ }
97
+
98
+ keys: dict[int, str] = {}
99
+ for position, s in enumerate(steps_list):
100
+ sid = id(s)
101
+ if sid in overrides:
102
+ keys[sid] = overrides[sid]
103
+ continue
104
+ candidate_slug = label_slugs.get(sid)
105
+ if (
106
+ candidate_slug is not None
107
+ and candidate_slug not in reserved
108
+ and slug_counts[candidate_slug] == 1
109
+ ):
110
+ keys[sid] = candidate_slug
111
+ reserved.add(candidate_slug)
112
+ continue
113
+ # Fall back to hash. Parent resolved key may not be in `keys`
114
+ # yet; use the empty string as a sentinel — call sites that
115
+ # need the resolved parent_key pass it explicitly via the
116
+ # lowering pass (see pipeline.py).
117
+ parent_key = ""
118
+ if s.parent is not None and id(s.parent) in keys:
119
+ parent_key = keys[id(s.parent)]
120
+ keys[sid] = hash_key(parent_key, s.cmd or "", position)
121
+ return keys
harmont/_registry.py ADDED
@@ -0,0 +1,44 @@
1
+ """Module-level registry of @pipeline-decorated functions.
2
+
3
+ Stage 1 (`dump_registry_json` in `_envelope`) walks REGISTRATIONS to
4
+ emit the envelope JSON the api/cli consume.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from .triggers import Trigger
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class PipelineRegistration:
19
+ slug: str
20
+ name: str
21
+ triggers: tuple[Trigger, ...]
22
+ allow_manual: bool
23
+ env: dict[str, str] | None
24
+ default_image: str | None
25
+ fn: Callable[[], object]
26
+
27
+
28
+ REGISTRATIONS: list[PipelineRegistration] = []
29
+
30
+
31
+ def register(reg: PipelineRegistration) -> None:
32
+ """Append a registration; raise on duplicate slug."""
33
+ if any(r.slug == reg.slug for r in REGISTRATIONS):
34
+ msg = (
35
+ f"duplicate pipeline slug {reg.slug!r}\n"
36
+ f" → each @hm.pipeline must have a unique slug"
37
+ )
38
+ raise ValueError(msg)
39
+ REGISTRATIONS.append(reg)
40
+
41
+
42
+ def clear_registry() -> None:
43
+ """Wipe REGISTRATIONS. Test-fixture helper; not part of the public surface."""
44
+ REGISTRATIONS.clear()