Skip to content

Commit b163aba

Browse files
feat(dsl): chainable .setup() on all toolchains (Python + TypeScript) (#151)
1 parent bc48683 commit b163aba

17 files changed

Lines changed: 407 additions & 15 deletions

File tree

crates/hm-dsl-engine/harmont-py/harmont/_cmake.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323

2424
import re
2525
from dataclasses import dataclass
26-
from typing import TYPE_CHECKING, Any, overload
26+
from typing import TYPE_CHECKING, Any, Self, overload
2727

28-
from ._toolchain import make_install_chain
28+
from ._toolchain import advance_install, make_install_chain
2929
from .cache import CacheForever, CacheOnChange
3030

3131
if TYPE_CHECKING:
@@ -232,6 +232,28 @@ class CMakeToolchain:
232232
ccache: bool
233233
generator: str
234234

235+
def setup(
236+
self,
237+
cmd: str,
238+
*,
239+
cwd: str | None = None,
240+
label: str | None = None,
241+
cache: CachePolicy | None = None,
242+
env: dict[str, str] | None = None,
243+
) -> Self:
244+
"""Append a post-install command and return an advanced toolchain; chainable.
245+
246+
Use for prep steps the toolchain's actions must depend on but that the SDK
247+
does not model natively — code generation, fixtures, extra tooling. Every
248+
``CMakeProject`` spawned from the returned toolchain forks from this step,
249+
so prep runs before the configure+build.
250+
251+
Examples:
252+
>>> import harmont as hm
253+
>>> tc = hm.cmake().setup("./scripts/gen-headers.sh")
254+
"""
255+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
256+
235257
def project(
236258
self,
237259
*,

crates/hm-dsl-engine/harmont-py/harmont/_elixir.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
import re
1212
from dataclasses import dataclass
1313
from dataclasses import field as dataclass_field
14-
from typing import TYPE_CHECKING, Any
14+
from typing import TYPE_CHECKING, Any, Self
1515

16-
from ._toolchain import make_install_chain
17-
from .cache import CacheForever, CacheOnChange
16+
from ._toolchain import advance_install, make_install_chain
17+
from .cache import CacheForever, CacheOnChange, CachePolicy
1818

1919
if TYPE_CHECKING:
2020
from ._step import Step
@@ -73,6 +73,29 @@ class ElixirProject:
7373
installed: Step
7474
_plt_step: Step | None = dataclass_field(default=None, init=False, repr=False)
7575

76+
def setup(
77+
self,
78+
cmd: str,
79+
*,
80+
cwd: str | None = None,
81+
label: str | None = None,
82+
cache: CachePolicy | None = None,
83+
env: dict[str, str] | None = None,
84+
) -> Self:
85+
"""Append a post-install command and return an advanced project; chainable.
86+
87+
Use for prep steps the toolchain's actions must depend on but that the SDK
88+
does not model natively — code generation, fixtures, extra tooling. The
89+
returned object's action methods (``compile``/``test``/…) fork from this
90+
step, so they all see its results.
91+
92+
Examples:
93+
>>> import harmont as hm
94+
>>> proj = hm.elixir(path="elixir").setup("mix proto.gen")
95+
>>> hm.pipeline([proj.compile(), proj.test()])
96+
"""
97+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
98+
7699
def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step:
77100
if kw.get("label") is None:
78101
kw["label"] = default_label

crates/hm-dsl-engine/harmont-py/harmont/_go.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99

1010
import re
1111
from dataclasses import dataclass
12-
from typing import TYPE_CHECKING, Any
12+
from typing import TYPE_CHECKING, Any, Self
1313

14-
from ._toolchain import make_install_chain
14+
from ._toolchain import advance_install, make_install_chain
1515
from .cache import CacheForever
1616

1717
if TYPE_CHECKING:
1818
from ._step import Step
19+
from .cache import CachePolicy
1920

2021
APT_PACKAGES = ("curl", "ca-certificates", "git")
2122

@@ -46,6 +47,27 @@ class GoToolchain:
4647
path: str
4748
installed: Step
4849

50+
def setup(
51+
self,
52+
cmd: str,
53+
*,
54+
cwd: str | None = None,
55+
label: str | None = None,
56+
cache: CachePolicy | None = None,
57+
env: dict[str, str] | None = None,
58+
) -> Self:
59+
"""Append a post-install command and return an advanced toolchain; chainable.
60+
61+
Use for prep steps the toolchain's actions must depend on but that the SDK
62+
does not model natively — code generation, fixtures, extra tooling. The
63+
returned object's action methods fork from this step.
64+
65+
Examples:
66+
>>> import harmont as hm
67+
>>> tc = hm.go(path=".").setup("go generate ./...")
68+
"""
69+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
70+
4971
def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step:
5072
if kw.get("label") is None:
5173
kw["label"] = default_label

crates/hm-dsl-engine/harmont-py/harmont/_js.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@
2121

2222
import re
2323
from dataclasses import dataclass
24-
from typing import TYPE_CHECKING, Any, Literal
24+
from typing import TYPE_CHECKING, Any, Literal, Self
2525

2626
from ._detect import detect
2727
from ._toolchain import (
28+
advance_install,
2829
bun_install_cmd,
2930
deno_install_cmd,
3031
make_install_chain,
@@ -34,6 +35,7 @@
3435

3536
if TYPE_CHECKING:
3637
from ._step import Step
38+
from .cache import CachePolicy
3739

3840
Runtime = Literal["node", "bun", "deno"]
3941
PackageManager = Literal["npm", "pnpm", "yarn-classic", "yarn-berry", "bun", "deno"]
@@ -124,6 +126,27 @@ class JsProject:
124126
run_prefix: str
125127
tag: str
126128

129+
def setup(
130+
self,
131+
cmd: str,
132+
*,
133+
cwd: str | None = None,
134+
label: str | None = None,
135+
cache: CachePolicy | None = None,
136+
env: dict[str, str] | None = None,
137+
) -> Self:
138+
"""Append a post-install command and return an advanced project; chainable.
139+
140+
Use for prep steps the toolchain's actions must depend on but that the SDK
141+
does not model natively — code generation, fixtures, extra tooling. The
142+
returned object's action methods fork from this step.
143+
144+
Examples:
145+
>>> import harmont as hm
146+
>>> proj = hm.js.project(path=".").setup("npm run codegen")
147+
"""
148+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
149+
127150
def install(self) -> Step:
128151
"""Return the dependency-install step (the unambiguous default leaf)."""
129152
return self.installed

crates/hm-dsl-engine/harmont-py/harmont/_python.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717

1818
import re
1919
from dataclasses import dataclass
20-
from typing import TYPE_CHECKING, Any
20+
from typing import TYPE_CHECKING, Any, Self
2121

22-
from ._toolchain import make_install_chain
22+
from ._toolchain import advance_install, make_install_chain
2323
from .cache import CacheForever, CacheOnChange
2424

2525
if TYPE_CHECKING:
2626
from ._step import Step
27+
from .cache import CachePolicy
2728

2829
APT_PACKAGES = ("curl", "ca-certificates", "python3", "python3-venv")
2930

@@ -60,6 +61,27 @@ class PythonToolchain:
6061
path: str
6162
installed: Step # uv-sync Step
6263

64+
def setup(
65+
self,
66+
cmd: str,
67+
*,
68+
cwd: str | None = None,
69+
label: str | None = None,
70+
cache: CachePolicy | None = None,
71+
env: dict[str, str] | None = None,
72+
) -> Self:
73+
"""Append a post-install command and return an advanced toolchain; chainable.
74+
75+
Use for prep steps the toolchain's actions must depend on but that the SDK
76+
does not model natively — code generation, fixtures, extra tooling. The
77+
returned object's action methods fork from this step.
78+
79+
Examples:
80+
>>> import harmont as hm
81+
>>> tc = hm.python(path=".").setup("uv run python scripts/codegen.py")
82+
"""
83+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
84+
6385
def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step:
6486
if kw.get("label") is None:
6587
kw["label"] = default_label

crates/hm-dsl-engine/harmont-py/harmont/_rust.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
import re
1212
import shlex
1313
from dataclasses import dataclass
14-
from typing import TYPE_CHECKING, Any
14+
from typing import TYPE_CHECKING, Any, Self
1515

1616
from ._cargo import CargoOpts, cargo_flags
17-
from ._toolchain import make_install_chain
17+
from ._toolchain import advance_install, make_install_chain
1818
from .cache import CacheForever, CacheOnChange
1919

2020
if TYPE_CHECKING:
@@ -151,6 +151,28 @@ class RustToolchain:
151151
path: str
152152
installed: Step
153153

154+
def setup(
155+
self,
156+
cmd: str,
157+
*,
158+
cwd: str | None = None,
159+
label: str | None = None,
160+
cache: CachePolicy | None = None,
161+
env: dict[str, str] | None = None,
162+
) -> Self:
163+
"""Append a post-install command and return an advanced toolchain; chainable.
164+
165+
Use for prep steps the toolchain's actions must depend on but that the SDK
166+
does not model natively — code generation, fixtures, extra tooling. The
167+
returned object's action methods (and projects spawned from it) fork from
168+
this step, so prep runs before the cargo warmup precompile.
169+
170+
Examples:
171+
>>> import harmont as hm
172+
>>> tc = hm.rust.toolchain().setup("cargo install sqlx-cli")
173+
"""
174+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
175+
154176
def _wrap(self, cargo: str) -> str:
155177
return f". $HOME/.cargo/env && cd {self.path} && {cargo}"
156178

crates/hm-dsl-engine/harmont-py/harmont/_toolchain.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313

1414
from __future__ import annotations
1515

16+
import dataclasses
1617
from datetime import timedelta
17-
from typing import TYPE_CHECKING
18+
from typing import TYPE_CHECKING, Protocol, TypeVar
1819

1920
from ._step import scratch
2021
from .cache import CacheTTL
@@ -27,6 +28,38 @@
2728
APT_TTL = timedelta(days=1)
2829

2930

31+
class _HasInstalled(Protocol):
32+
# Read-only member (property form) so frozen-dataclass toolchains, whose
33+
# `installed` field is read-only, satisfy the protocol. A bare
34+
# `installed: Step` annotation declares a *writable* member, which frozen
35+
# instances do not match.
36+
@property
37+
def installed(self) -> Step: ...
38+
39+
40+
_ProjectT = TypeVar("_ProjectT", bound="_HasInstalled")
41+
42+
43+
def advance_install(
44+
project: _ProjectT,
45+
cmd: str,
46+
*,
47+
cwd: str | None = None,
48+
label: str | None = None,
49+
cache: CachePolicy | None = None,
50+
env: dict[str, str] | None = None,
51+
) -> _ProjectT:
52+
"""Return a copy of a toolchain object with one command appended to its
53+
install chain. Every action method emitted from the returned object forks
54+
from the new step. Shared implementation behind each toolchain's ``setup()``.
55+
"""
56+
new_installed = project.installed.sh(cmd, cwd=cwd, label=label, cache=cache, env=env)
57+
# All callers are frozen dataclasses carrying an `installed: Step` field, but
58+
# the Protocol bound cannot express "is a dataclass", so the replace below
59+
# cannot satisfy its DataclassInstance upper bound — hence the narrow ignore.
60+
return dataclasses.replace(project, installed=new_installed) # ty: ignore[invalid-argument-type]
61+
62+
3063
def apt_install_cmd(packages: tuple[str, ...]) -> str:
3164
"""Single shell string: ``apt-get update && apt-get install -y <pkgs>``."""
3265
pkgs = " ".join(packages)

crates/hm-dsl-engine/harmont-py/harmont/_zig.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919

2020
import re
2121
from dataclasses import dataclass
22-
from typing import TYPE_CHECKING, Any, overload
22+
from typing import TYPE_CHECKING, Any, Self, overload
2323

24-
from ._toolchain import make_install_chain
24+
from ._toolchain import advance_install, make_install_chain
2525
from .cache import CacheForever
2626

2727
if TYPE_CHECKING:
2828
from ._step import Step
29+
from .cache import CachePolicy
2930

3031
APT_PACKAGES = ("curl", "ca-certificates", "xz-utils")
3132

@@ -66,6 +67,27 @@ class ZigProject:
6667
path: str
6768
installed: Step
6869

70+
def setup(
71+
self,
72+
cmd: str,
73+
*,
74+
cwd: str | None = None,
75+
label: str | None = None,
76+
cache: CachePolicy | None = None,
77+
env: dict[str, str] | None = None,
78+
) -> Self:
79+
"""Append a post-install command and return an advanced project; chainable.
80+
81+
Use for prep steps the toolchain's actions must depend on but that the SDK
82+
does not model natively — code generation, fixtures, extra tooling. The
83+
returned object's action methods fork from this step.
84+
85+
Examples:
86+
>>> import harmont as hm
87+
>>> proj = hm.zig(path=".").setup("zig build gen")
88+
"""
89+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
90+
6991
def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step:
7092
if kw.get("label") is None:
7193
kw["label"] = default_label
@@ -106,6 +128,28 @@ class ZigToolchain:
106128
version: str
107129
installed: Step
108130

131+
def setup(
132+
self,
133+
cmd: str,
134+
*,
135+
cwd: str | None = None,
136+
label: str | None = None,
137+
cache: CachePolicy | None = None,
138+
env: dict[str, str] | None = None,
139+
) -> Self:
140+
"""Append a post-install command and return an advanced toolchain; chainable.
141+
142+
Use for prep steps the toolchain's actions must depend on but that the SDK
143+
does not model natively — code generation, fixtures, extra tooling. Every
144+
``ZigProject`` spawned from the returned toolchain forks from this step.
145+
146+
Examples:
147+
>>> import harmont as hm
148+
>>> tc = hm.zig().setup("zig build gen")
149+
>>> hm.pipeline([tc.project("lib-a").test()])
150+
"""
151+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
152+
109153
def project(self, path: str = ".") -> ZigProject:
110154
"""Create a ``ZigProject`` rooted at ``path`` from this toolchain.
111155

0 commit comments

Comments
 (0)