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 +168 -0
- harmont/_decorator.py +68 -0
- harmont/_deps.py +188 -0
- harmont/_envelope.py +100 -0
- harmont/_keys.py +121 -0
- harmont/_registry.py +44 -0
- harmont/_step.py +99 -0
- harmont/_target.py +104 -0
- harmont/_toolchain.py +79 -0
- harmont/_typing.py +97 -0
- harmont/_unwrap.py +56 -0
- harmont/_validation.py +11 -0
- harmont/cache.py +80 -0
- harmont/cmake.py +127 -0
- harmont/composer.py +109 -0
- harmont/dotnet.py +116 -0
- harmont/elm.py +143 -0
- harmont/go.py +117 -0
- harmont/gradle.py +137 -0
- harmont/haskell.py +257 -0
- harmont/json_emit.py +69 -0
- harmont/keygen.py +156 -0
- harmont/npm.py +118 -0
- harmont/ocaml.py +145 -0
- harmont/perl.py +86 -0
- harmont/pipeline.py +172 -0
- harmont/python.py +141 -0
- harmont/ruby.py +108 -0
- harmont/rust.py +139 -0
- harmont/triggers.py +135 -0
- harmont/types.py +12 -0
- harmont/zig.py +172 -0
- harmont-0.0.1.dist-info/METADATA +186 -0
- harmont-0.0.1.dist-info/RECORD +37 -0
- harmont-0.0.1.dist-info/WHEEL +5 -0
- harmont-0.0.1.dist-info/licenses/LICENSE +21 -0
- harmont-0.0.1.dist-info/top_level.txt +1 -0
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()
|