|
| 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