Skip to content

Commit 879008c

Browse files
Feat: Add Scala SDK (#164)
Co-authored-by: Marko Vejnovic <marko@harmont.dev>
1 parent 05b4d7d commit 879008c

17 files changed

Lines changed: 706 additions & 5 deletions

File tree

‎.github/workflows/examples.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ jobs:
8484
- python-uv
8585
- react
8686
- rust
87+
- scala
8788
- typescript
8889
- zig-js
8990
- zig

‎crates/hm-dsl-engine/harmont-py/harmont/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from ._pipeline import pipeline_to_json
4141
from ._python import python
4242
from ._rust import RustProject, rust
43+
from ._scala import ScalaProject, scala
4344
from ._step import Step, scratch, wait
4445
from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests
4546
from ._toolchain import apt_base
@@ -309,6 +310,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]:
309310
"JsProject",
310311
"Pipeline",
311312
"RustProject",
313+
"ScalaProject",
312314
"Step",
313315
"Target",
314316
"apt_base",
@@ -329,6 +331,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]:
329331
"py",
330332
"python",
331333
"rust",
334+
"scala",
332335
"scratch",
333336
"sh",
334337
"target",
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
"""Scala toolchain and CI-pipeline DSL.
2+
3+
Public surface is the module-level ``scala`` namespace:
4+
5+
hm.scala.toolchain(...) -> ScalaToolchain (install-only)
6+
hm.scala.project(...) -> ScalaProject (full CI DAG)
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
import shlex
13+
from dataclasses import dataclass
14+
from typing import TYPE_CHECKING, Any, Self
15+
16+
from ._toolchain import advance_install, make_install_chain
17+
from .cache import CacheForever, CacheOnChange
18+
19+
if TYPE_CHECKING:
20+
from ._step import Step
21+
from .cache import CachePolicy
22+
23+
APT_PACKAGES = ("curl", "ca-certificates", "openjdk-17-jdk-headless")
24+
_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") # e.g. 3.3.8
25+
_MIN_VERSION = (2, 13, 0)
26+
27+
28+
def _parse_version(version: str) -> tuple[int, ...]:
29+
return tuple(int(p) for p in version.split("."))
30+
31+
32+
def coursier_install_cmd(version: str = "3.3.8") -> str:
33+
return (
34+
"curl -fL https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz "
35+
"| gzip -d > cs && "
36+
"chmod +x cs && "
37+
"./cs setup --yes && "
38+
f"./cs install scala:{version} && "
39+
"ln -sf /root/.local/share/coursier/bin/* /usr/local/bin/"
40+
)
41+
42+
43+
def _compile_cmd(*, query: str | None = None, test: bool = False) -> str:
44+
scope = ""
45+
if query:
46+
scope += f"{query} / "
47+
if test:
48+
scope += "Test / "
49+
return f"sbt {scope}compile"
50+
51+
52+
# sbt [<query> / ] test [<testname> …] [ -- <options> ]
53+
def _test_cmd(
54+
*,
55+
query: str | None = None,
56+
testnames: tuple[str, ...] = (),
57+
options: dict[str, Any] | None = None,
58+
) -> str:
59+
scope = f"{query} / " if query else ""
60+
names = (" " + " ".join(testnames)) if testnames else ""
61+
opts = ""
62+
if options:
63+
rendered = " ".join(f"{key} {shlex.quote(str(value))}" for key, value in options.items())
64+
opts = f" -- {rendered}"
65+
return f"sbt {scope}test{names}{opts}"
66+
67+
68+
def _fmt_cmd(*, check: bool = True) -> str:
69+
return "sbt scalafmtCheckAll" if check else "sbt scalafmtAll"
70+
71+
72+
@dataclass(frozen=True)
73+
class ScalaToolchain:
74+
"""Scala install chain built by ``hm.scala.toolchain()``.
75+
76+
Holds the coursier install step. Action methods (``compile``, ``test``,
77+
``fmt``, ``warmup``) attach leaves to ``installed`` and forward Step kwargs
78+
(``label``, ``cache``, ``env``, ``image`` …) unchanged.
79+
"""
80+
81+
path: str
82+
installed: Step
83+
84+
def setup(
85+
self,
86+
cmd: str,
87+
*,
88+
cwd: str | None = None,
89+
label: str | None = None,
90+
cache: CachePolicy | None = None,
91+
env: dict[str, str] | None = None,
92+
) -> Self:
93+
"""Append a post-install command and return an advanced toolchain; chainable.
94+
95+
Use for prep steps the toolchain's actions must depend on but that the SDK
96+
does not model natively — code generation, fixtures, extra tooling. The
97+
returned object's action methods fork from this step.
98+
99+
Examples:
100+
>>> import harmont as hm
101+
>>> tc = hm.scala.toolchain(path=".").setup("cs install scalafix")
102+
"""
103+
return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env)
104+
105+
def _wrap(self, sbt_cmd: str) -> str:
106+
return (
107+
f'export PATH="$PATH:$HOME/.local/share/coursier/bin" && cd {self.path} && {sbt_cmd}'
108+
)
109+
110+
def _emit(self, sbt_cmd: str, default_label: str, **kw: Any) -> Step:
111+
if kw.get("label") is None:
112+
kw["label"] = default_label
113+
return self.installed.sh(self._wrap(sbt_cmd), **kw)
114+
115+
def compile(self, *, query: str | None = None, test: bool = False, **kw: Any) -> Step:
116+
"""Compile the project (``sbt compile``); ``test=True`` compiles the test
117+
sources (``sbt Test / compile``)."""
118+
cmd = _compile_cmd(query=query, test=test)
119+
return self._emit(cmd, f":scala: {self.path} compile", **kw)
120+
121+
def test(
122+
self,
123+
*,
124+
query: str | None = None,
125+
testnames: tuple[str, ...] = (),
126+
options: dict[str, Any] | None = None,
127+
**kw: Any,
128+
) -> Step:
129+
"""Run the test suite (``sbt test``). ``query`` scopes to a subproject,
130+
``testnames`` selects specific suites, ``options`` pass through after ``--``."""
131+
cmd = _test_cmd(query=query, testnames=testnames, options=options)
132+
return self._emit(cmd, f":scala: {self.path} test", **kw)
133+
134+
def fmt(self, *, check: bool = True, **kw: Any) -> Step:
135+
"""Format Scala sources. ``check=True`` (default) verifies formatting via
136+
``sbt scalafmtCheckAll`` and fails on drift; ``check=False`` rewrites in
137+
place via ``sbt scalafmtAll``."""
138+
cmd = _fmt_cmd(check=check)
139+
return self._emit(cmd, f":scala: {self.path} format", **kw)
140+
141+
def warmup(self, **kw: Any) -> Step:
142+
"""Pre-resolve dependencies (``sbt update``) so later steps reuse the cache.
143+
Used internally by ``hm.scala.project()``."""
144+
return self._emit("sbt update", ":scala: warmup", **kw)
145+
146+
147+
@dataclass(frozen=True)
148+
class ScalaProject:
149+
"""High-level Scala CI DAG built by ``hm.scala.project()``.
150+
151+
``compile`` and ``test`` attach to a shared ``warmup`` step so dependency
152+
resolution is reused; ``fmt`` forks off the toolchain install to run in
153+
parallel. ``ci()`` returns the standard DAG in one call.
154+
"""
155+
156+
path: str
157+
toolchain: ScalaToolchain
158+
warmup: Step
159+
160+
def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step:
161+
if kw.get("label") is None:
162+
kw["label"] = default_label
163+
return self.warmup.sh(self.toolchain._wrap(cmd), **kw) # noqa: SLF001
164+
165+
def compile(self, *, query: str | None = None, test: bool = False, **kw: Any) -> Step:
166+
"""Compile the project (``sbt compile``); ``test=True`` compiles the test
167+
sources (``sbt Test / compile``)."""
168+
cmd = _compile_cmd(query=query, test=test)
169+
return self._emit(cmd, f":scala: {self.path} sbt compile", **kw)
170+
171+
def test(
172+
self,
173+
*,
174+
query: str | None = None,
175+
testnames: tuple[str, ...] = (),
176+
options: dict[str, Any] | None = None,
177+
**kw: Any,
178+
) -> Step:
179+
"""Run the test suite (``sbt test``). ``query`` scopes to a subproject,
180+
``testnames`` selects specific suites, ``options`` pass through after ``--``."""
181+
cmd = _test_cmd(query=query, testnames=testnames, options=options)
182+
return self._emit(cmd, f":scala: {self.path} sbt test", **kw)
183+
184+
def fmt(self, *, check: bool = True, **kw: Any) -> Step:
185+
"""Format Scala sources (``sbt scalafmtCheckAll`` when ``check=True``,
186+
default). Forks off the toolchain install so it runs in parallel with the
187+
warmup rather than waiting on dependency resolution."""
188+
return self.toolchain.fmt(check=check, **kw)
189+
190+
def ci(self) -> tuple[Step, ...]:
191+
"""Zero-config Scala CI DAG: ``compile`` and ``test`` off the shared warmup,
192+
``fmt`` (verify) in parallel off the install.
193+
194+
Examples:
195+
>>> import harmont as hm
196+
>>> proj = hm.scala.project()
197+
>>> hm.pipeline(list(proj.ci()))
198+
"""
199+
return self.compile(), self.test(), self.fmt()
200+
201+
202+
def _make_scala(
203+
*,
204+
path: str = ".",
205+
version: str = "3.3.8",
206+
image: str | None = None,
207+
base: Step | None = None,
208+
) -> ScalaToolchain:
209+
if not _VERSION_RE.match(version):
210+
msg = f"hm.scala: invalid version {version!r}\n -> use a valid X.Y.Z Scala version"
211+
raise ValueError(msg)
212+
213+
if _parse_version(version) < _MIN_VERSION:
214+
msg = f"hm.scala: unsupported version {version!r}\n -> use Scala 2.13.0 or newer"
215+
raise ValueError(msg)
216+
217+
installed = make_install_chain(
218+
apt_packages=APT_PACKAGES,
219+
install_cmd=coursier_install_cmd(version),
220+
install_cache=CacheForever(env_keys=()),
221+
lang_tag="scala",
222+
install_tag="install",
223+
image=image,
224+
base=base,
225+
)
226+
return ScalaToolchain(path=path, installed=installed)
227+
228+
229+
def _make_scala_project(
230+
*,
231+
path: str = ".",
232+
version: str = "3.3.8",
233+
image: str | None = None,
234+
base: Step | None = None,
235+
cache: CachePolicy | None = None,
236+
) -> ScalaProject:
237+
tc = _make_scala(path=path, version=version, image=image, base=base)
238+
239+
if not path:
240+
path = "."
241+
242+
prefix = "" if path == "." else f"{path}/"
243+
build_file = f"{prefix}build.sbt"
244+
properties_file = f"{prefix}project/build.properties"
245+
main_glob = f"{prefix}**/src/main/*.scala"
246+
test_glob = f"{prefix}**/src/test/*.scala"
247+
248+
warmup_cache = cache or CacheOnChange(
249+
paths=(build_file, main_glob, test_glob, properties_file)
250+
)
251+
252+
warm = tc._emit("sbt update", ":scala: sbt warmup", cache=warmup_cache) # noqa: SLF001
253+
return ScalaProject(path=path, toolchain=tc, warmup=warm)
254+
255+
256+
class ScalaEntry:
257+
"""Namespace for ``hm.scala.toolchain()`` and ``hm.scala.project()``."""
258+
259+
@staticmethod
260+
def toolchain(
261+
*,
262+
path: str = ".",
263+
scala_version: str = "3.3.8",
264+
image: str | None = None,
265+
base: Step | None = None,
266+
) -> ScalaToolchain:
267+
"""Install the Scala toolchain via coursier.
268+
269+
Produces a ``ScalaToolchain`` whose ``installed`` step runs coursier;
270+
action methods attach leaves to it. Use ``project()`` instead when you
271+
want a shared warmup step across compile/test/fmt.
272+
273+
Args:
274+
path: Path to the workspace root.
275+
scala_version: Pinned Scala version, e.g. ``"3.3.8"`` (2.13.0 or newer).
276+
image: Local-mode Docker base image override.
277+
base: Existing ``Step`` to attach the install to instead of emitting
278+
a fresh apt-base step. Use to share one apt-base across toolchains.
279+
280+
Returns:
281+
A ``ScalaToolchain`` ready for action methods.
282+
283+
Raises:
284+
ValueError: If ``scala_version`` is malformed or older than 2.13.0.
285+
286+
Examples:
287+
>>> import harmont as hm
288+
>>> tc = hm.scala.toolchain(scala_version="3.3.8")
289+
>>> hm.pipeline([tc.compile(), tc.test()])
290+
"""
291+
return _make_scala(path=path, version=scala_version, image=image, base=base)
292+
293+
@staticmethod
294+
def project(
295+
*,
296+
path: str = ".",
297+
scala_version: str = "3.3.8",
298+
image: str | None = None,
299+
base: Step | None = None,
300+
cache: CachePolicy | None = None,
301+
) -> ScalaProject:
302+
"""Build a high-level Scala CI DAG.
303+
304+
Installs the toolchain via coursier, warms a dependency cache keyed on
305+
``build.sbt``/``project/build.properties``/``**/*.scala``, and returns a
306+
``ScalaProject`` whose ``compile``/``test``/``fmt`` build on that warmup.
307+
308+
Args:
309+
path: Path to the workspace root.
310+
scala_version: Pinned Scala version, e.g. ``"3.3.8"`` (2.13.0 or newer).
311+
image: Local-mode Docker base image override.
312+
base: Existing ``Step`` to attach the install to instead of emitting
313+
a fresh apt-base step.
314+
cache: Override the warmup cache policy. Defaults to ``CacheOnChange``
315+
keyed on the build files and ``**/*.scala`` sources.
316+
317+
Returns:
318+
A ``ScalaProject`` exposing the common CI steps.
319+
320+
Raises:
321+
ValueError: If ``scala_version`` is malformed or older than 2.13.0.
322+
323+
Examples:
324+
>>> import harmont as hm
325+
>>> proj = hm.scala.project()
326+
>>> hm.pipeline(list(proj.ci()))
327+
"""
328+
return _make_scala_project(
329+
path=path,
330+
version=scala_version,
331+
image=image,
332+
base=base,
333+
cache=cache,
334+
)
335+
336+
337+
scala: ScalaEntry = ScalaEntry()

0 commit comments

Comments
 (0)