From 70360e6f47dae5eb0f31e40ab47f51821f4cd0f1 Mon Sep 17 00:00:00 2001 From: amannocci Date: Tue, 14 Jul 2026 17:15:47 +0200 Subject: [PATCH 1/3] refactor: restructure scripts with binds layer and update error handling --- pyproject.toml | 14 ++-- scripts/binds/__init__.py | 16 ++++ scripts/binds/basedpyright.py | 38 +++++++++ scripts/binds/container.py | 107 +++++++++++++++++++++++++ scripts/binds/gh.py | 50 ++++++++++++ scripts/binds/git.py | 101 ++++++++++++++++++++++++ scripts/binds/pre_commit.py | 33 ++++++++ scripts/binds/pyinstaller.py | 33 ++++++++ scripts/binds/ruff.py | 38 +++++++++ scripts/binds/uv.py | 51 ++++++++++++ scripts/build.py | 109 -------------------------- scripts/tasks/__init__.py | 16 ++++ scripts/tasks/build.py | 66 ++++++++++++++++ scripts/{ => tasks}/env.py | 6 +- scripts/{ => tasks}/generate.py | 5 +- scripts/{ => tasks}/lint.py | 49 ++++-------- scripts/{ => tasks}/release.py | 70 +++++------------ scripts/utils.py | 134 -------------------------------- 18 files changed, 598 insertions(+), 338 deletions(-) create mode 100644 scripts/binds/__init__.py create mode 100644 scripts/binds/basedpyright.py create mode 100644 scripts/binds/container.py create mode 100644 scripts/binds/gh.py create mode 100644 scripts/binds/git.py create mode 100644 scripts/binds/pre_commit.py create mode 100644 scripts/binds/pyinstaller.py create mode 100644 scripts/binds/ruff.py create mode 100644 scripts/binds/uv.py delete mode 100644 scripts/build.py create mode 100644 scripts/tasks/__init__.py create mode 100644 scripts/tasks/build.py rename scripts/{ => tasks}/env.py (83%) rename scripts/{ => tasks}/generate.py (90%) rename scripts/{ => tasks}/lint.py (51%) rename scripts/{ => tasks}/release.py (71%) diff --git a/pyproject.toml b/pyproject.toml index 692945e..715b3f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ reportUnusedCallResult = false [tool.poe.tasks."env:configure"] help = "Setup project environment." -script = "scripts.env:configure" +script = "scripts.tasks.env:configure" [tool.poe.tasks."project:upgrade"] help = "Upgrade project dependencies." @@ -90,11 +90,11 @@ interpreter = "python" [tool.poe.tasks.generate] help = "Generate pyinstaller config." -script = "scripts.generate:run" +script = "scripts.tasks.generate:run" [tool.poe.tasks.lint] help = "Lint code project." -script = "scripts.lint:run" +script = "scripts.tasks.lint:run" [tool.poe.tasks.fmt] help = "Format code project." @@ -106,7 +106,7 @@ sequence = [ [tool.poe.tasks.build] deps = ["env:wipe"] help = "Build standalone binary." -script = "scripts.build:run" +script = "scripts.tasks.build:run" [tool.poe.tasks."test"] help = "Run all tests." @@ -118,12 +118,12 @@ cmd = "pytest --junitxml=reports/junit-report.xml -n=auto --timeout=900 --dist=l [tool.poe.tasks."release:pre"] help = "Create a PR with changes for release." -script = "scripts.release:pre" +script = "scripts.tasks.release:pre" [tool.poe.tasks."release"] help = "Create a new terranova release." -script = "scripts.release:run" +script = "scripts.tasks.release:run" [tool.poe.tasks."release:post"] help = "Prepare next iteration." -script = "scripts.release:post" +script = "scripts.tasks.release:post" diff --git a/scripts/binds/__init__.py b/scripts/binds/__init__.py new file mode 100644 index 0000000..8d469bb --- /dev/null +++ b/scripts/binds/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/scripts/binds/basedpyright.py b/scripts/binds/basedpyright.py new file mode 100644 index 0000000..21d4e6b --- /dev/null +++ b/scripts/binds/basedpyright.py @@ -0,0 +1,38 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound + + +class BasedPyright(Bind): + """Represents a bind to basedpyright command.""" + + def __init__(self) -> None: + """Init basedpyright bind.""" + try: + super().__init__("basedpyright") + except CommandNotFound as err: + fatal("detect basedpyright binary", err) + + def check(self) -> None: + """ + Type check the project. + + Raises: + ErrorReturnCode: if type errors are found. + """ + self._cmd.inherit_out().exec() diff --git a/scripts/binds/container.py b/scripts/binds/container.py new file mode 100644 index 0000000..8d1565b --- /dev/null +++ b/scripts/binds/container.py @@ -0,0 +1,107 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from io import StringIO +from pathlib import Path +from typing import override + +from scripts.utils import fatal +from terranova.process import Bind, Command, CommandNotFound, EnvCmd + + +class Container(Bind): + """Represents a bind to a container backend, either docker or podman.""" + + def __init__(self) -> None: + """Init container bind.""" + backend: str | None = None + for candidate in ("docker", "podman"): + try: + Command(candidate) + backend = candidate + break + except CommandNotFound: + continue + + if backend is None: + fatal( + "detect container backend", + CommandNotFound("docker or podman"), + ) + + self.__backend: str = backend + super().__init__(backend) + + @override + def create(self, cmd_path: str | Path) -> Command: + env = EnvCmd.inherit() + if self.__backend == "podman": + env.add({"BUILDAH_FORMAT": "docker"}) + return Command(cmd_path).env(env.build()) + + def build_image(self, platform: str, python_version: str, tag: str) -> None: + """Build a multi-platform image via buildx.""" + ( + self._cmd.args( + "buildx", + "build", + "--load", + "--platform", + platform, + "--build-arg", + f"base_image_version={python_version}", + "-t", + tag, + "-f", + "Containerfile", + ".", + ) + .inherit_out() + .exec() + ) + + def run_detached(self, platform: str, image: str) -> str: + """Run a detached container with a no-op entrypoint and return its id.""" + capture = StringIO() + ( + self._cmd.args( + "run", + "-d", + "--platform", + platform, + "--entrypoint=cat", + image, + ) + .stdout(capture) + .exec() + ) + return capture.getvalue().strip() + + def copy_from(self, container_id: str, container_path: str, dest: Path) -> None: + """Copy a path out of a container.""" + ( + self._cmd.args( + "cp", + f"{container_id}:{container_path}", + dest.as_posix(), + ) + .inherit_out() + .exec() + ) + + def remove(self, container_id: str) -> None: + """Remove a container.""" + self._cmd.args("rm", "-f", container_id).exec() diff --git a/scripts/binds/gh.py b/scripts/binds/gh.py new file mode 100644 index 0000000..7c2d8ae --- /dev/null +++ b/scripts/binds/gh.py @@ -0,0 +1,50 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound + + +class Gh(Bind): + """Represents a bind to gh command.""" + + def __init__(self) -> None: + """Init gh bind.""" + try: + super().__init__("gh") + except CommandNotFound as err: + fatal("detect gh binary", err) + + def pr_create(self, base: str, head: str) -> None: + """Create a pull request, filling title/body from commits.""" + ( + self._cmd.args("pr", "create", "--fill", f"--base={base}", f"--head={head}") + .inherit_out() + .exec() + ) + + def release_create(self, version: str, title: str, binaries: list[str]) -> None: + """Create a release with generated notes and attached binaries.""" + args = [ + "release", + "create", + "--generate-notes", + "--latest", + f"--title={title}", + version, + *binaries, + ] + self._cmd.args(*args).inherit_out().exec() diff --git a/scripts/binds/git.py b/scripts/binds/git.py new file mode 100644 index 0000000..99dd58e --- /dev/null +++ b/scripts/binds/git.py @@ -0,0 +1,101 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import sys +from io import StringIO + +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound, ErrorReturnCode + + +class Git(Bind): + """Represents a bind to git command.""" + + def __init__(self) -> None: + """Init git bind.""" + try: + super().__init__("git") + except CommandNotFound as err: + fatal("detect git binary", err) + + def short_head(self) -> str: + """Return the short commit hash of HEAD.""" + capture = StringIO() + self._cmd.args("rev-parse", "--short", "HEAD").stdout(capture).exec() + return capture.getvalue().strip() + + def head(self) -> str: + """Return the full commit hash of HEAD.""" + capture = StringIO() + self._cmd.args("rev-parse", "HEAD").stdout(capture).stderr(sys.stderr).exec() + return capture.getvalue().strip() + + def current_branch(self) -> str: + """Return the name of the current branch.""" + capture = StringIO() + self._cmd.args("rev-parse", "--abbrev-ref", "HEAD").stdout(capture).exec() + return capture.getvalue().strip() + + def checkout(self, name: str, inherit_out: bool = False) -> None: + """Switch to an existing branch.""" + cmd = self._cmd.args("checkout", name) + if inherit_out: + cmd.inherit_out() + cmd.exec() + + def checkout_new_branch(self, name: str) -> None: + """Create and switch to a new branch.""" + self._cmd.args("checkout", "-b", name).inherit_out().exec() + + def add_all(self) -> None: + """Stage all changes.""" + self._cmd.args("add", "--all").inherit_out().exec() + + def commit(self, message: str, no_verify: bool = False) -> None: + """Commit staged changes.""" + args = ["commit", "-m", message] + if no_verify: + args.append("--no-verify") + self._cmd.args(*args).inherit_out().exec() + + def push(self, remote: str, ref: str, inherit_out: bool = True) -> None: + """Push a ref to a remote.""" + cmd = self._cmd.args("push", remote, ref) + if inherit_out: + cmd.inherit_out() + cmd.exec() + + def tag(self, name: str) -> None: + """ + Create a tag. + + Raises: + ErrorReturnCode: if the tag already exists. + """ + self._cmd.args("tag", name).exec() + + def branch_delete(self, name: str) -> None: + """Delete a branch if it exists.""" + try: + self._cmd.args("branch", "-D", name).inherit_out().exec() + except ErrorReturnCode: + pass + + def status_short(self) -> str: + """Return the short status of the working tree.""" + capture = StringIO() + self._cmd.args("status", "-s").stdout(capture).stderr(sys.stderr).exec() + return capture.getvalue().strip() diff --git a/scripts/binds/pre_commit.py b/scripts/binds/pre_commit.py new file mode 100644 index 0000000..3972031 --- /dev/null +++ b/scripts/binds/pre_commit.py @@ -0,0 +1,33 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound + + +class PreCommit(Bind): + """Represents a bind to pre-commit command.""" + + def __init__(self) -> None: + """Init pre-commit bind.""" + try: + super().__init__("pre-commit") + except CommandNotFound as err: + fatal("detect pre-commit binary", err) + + def install(self) -> None: + """Install the pre-commit hooks.""" + self._cmd.args("install").inherit_out().exec() diff --git a/scripts/binds/pyinstaller.py b/scripts/binds/pyinstaller.py new file mode 100644 index 0000000..dcf4633 --- /dev/null +++ b/scripts/binds/pyinstaller.py @@ -0,0 +1,33 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound + + +class PyInstaller(Bind): + """Represents a bind to pyinstaller command.""" + + def __init__(self) -> None: + """Init pyinstaller bind.""" + try: + super().__init__("pyinstaller") + except CommandNotFound as err: + fatal("detect pyinstaller binary", err) + + def run(self, *args: str) -> None: + """Run pyinstaller with the given arguments.""" + self._cmd.args(*args).inherit_out().exec() diff --git a/scripts/binds/ruff.py b/scripts/binds/ruff.py new file mode 100644 index 0000000..c3fc53c --- /dev/null +++ b/scripts/binds/ruff.py @@ -0,0 +1,38 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound + + +class Ruff(Bind): + """Represents a bind to ruff command.""" + + def __init__(self) -> None: + """Init ruff bind.""" + try: + super().__init__("ruff") + except CommandNotFound as err: + fatal("detect ruff binary", err) + + def check(self, path: str) -> None: + """ + Lint a path. + + Raises: + ErrorReturnCode: if lint violations are found. + """ + self._cmd.args("check", path).inherit_out().exec() diff --git a/scripts/binds/uv.py b/scripts/binds/uv.py new file mode 100644 index 0000000..d6daf1b --- /dev/null +++ b/scripts/binds/uv.py @@ -0,0 +1,51 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import re +import sys +from io import StringIO + +from scripts.utils import fatal +from terranova.process import Bind, CommandNotFound + + +class Uv(Bind): + """Represents a bind to uv command.""" + + def __init__(self) -> None: + """Init uv bind.""" + try: + super().__init__("uv") + except CommandNotFound as err: + fatal("detect uv binary", err) + + def project_version(self, package: str = "terranova") -> str: + """Return the installed version of a package.""" + capture = StringIO() + ( + self._cmd.args("pip", "show", package) + .stdout(capture) + .stderr(sys.stderr) + .exec() + ) + match = re.search(r"Version: (.*)", capture.getvalue()) + if not match: + fatal(f"detect {package} version") + return match.group(1).replace(".dev0", "-dev").strip() + + def run_poe(self, task: str) -> None: + """Run a poe task.""" + self._cmd.args("run", "poe", task).inherit().exec() diff --git a/scripts/build.py b/scripts/build.py deleted file mode 100644 index 437572a..0000000 --- a/scripts/build.py +++ /dev/null @@ -1,109 +0,0 @@ -# -# Copyright 2023-2025 Elasticsearch B.V. -# Copyright 2026-present Adrien Mannocci -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -import platform -import sys -from io import StringIO -from pathlib import Path -from time import time - -from scripts.utils import ( - Constants, - container_backend, - detect_git, - detect_pyinstaller, - project_version, -) - - -def run() -> None: - git = detect_git() - capture_stdout = StringIO() - git.args("rev-parse", "--short", "HEAD").stdout(capture_stdout).exec() - commit_hash_short = capture_stdout.getvalue().strip() - current_time_epoch = int(time()) - version = project_version() - python_version = platform.python_version() - - image_id = f"{version}-{current_time_epoch}-{commit_hash_short}" - - # Create dist dir - local_dist_path = Path("dist") - local_dist_path.mkdir(parents=True, exist_ok=False) - local_dist_path = local_dist_path.absolute() - - system = platform.system().lower() - if system == "darwin": - pyinstaller = detect_pyinstaller().inherit_out() - pyinstaller.args("terranova.spec").exec() - arch = platform.machine() - arch = "amd64" if arch == "x86_64" else arch - Path("./dist/terranova").replace( - Path(f"./dist/terranova-{version}-{system}-{arch}") - ) - elif system == "linux": - # Use cross-build to build both amd64 and arm64 versions. - cmd = container_backend() - for arch in ["amd64", "arm64"]: - platform_arch = f"linux/{arch}" - ( - cmd.args( - "buildx", - "build", - "--load", - "--platform", - platform_arch, - "--build-arg", - f"base_image_version={python_version}", - "-t", - f"{Constants.REGISTRY_URL}/terranova:{image_id}", - "-f", - "Containerfile", - ".", - ) - .inherit_out() - .exec() - ) - capture_stdout = StringIO() - ( - cmd.args( - "run", - "-d", - "--platform", - platform_arch, - "--entrypoint=cat", - f"{Constants.REGISTRY_URL}/terranova:{image_id}", - ) - .stdout(capture_stdout) - .exec() - ) - container_id = capture_stdout.getvalue().strip() - ( - cmd.args( - "cp", - f"{container_id}:/opt/terranova/dist/terranova", - (local_dist_path / f"terranova-{version}-linux-{arch}").as_posix(), - ) - .inherit_out() - .exec() - ) - cmd.args( - "rm", - "-f", - container_id, - ).exec() - else: - print(f"Unsupported system: {system}", file=sys.stderr) diff --git a/scripts/tasks/__init__.py b/scripts/tasks/__init__.py new file mode 100644 index 0000000..8d469bb --- /dev/null +++ b/scripts/tasks/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/scripts/tasks/build.py b/scripts/tasks/build.py new file mode 100644 index 0000000..8708725 --- /dev/null +++ b/scripts/tasks/build.py @@ -0,0 +1,66 @@ +# +# Copyright 2023-2025 Elasticsearch B.V. +# Copyright 2026-present Adrien Mannocci +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import platform +import sys +from pathlib import Path +from time import time + +from scripts.binds.container import Container +from scripts.binds.git import Git +from scripts.binds.pyinstaller import PyInstaller +from scripts.binds.uv import Uv +from scripts.utils import Constants + + +def run() -> None: + commit_hash_short = Git().short_head() + current_time_epoch = int(time()) + version = Uv().project_version() + python_version = platform.python_version() + + image_id = f"{version}-{current_time_epoch}-{commit_hash_short}" + + # Create dist dir + local_dist_path = Path("dist") + local_dist_path.mkdir(parents=True, exist_ok=False) + local_dist_path = local_dist_path.absolute() + + system = platform.system().lower() + match system: + case "darwin": + PyInstaller().run("terranova.spec") + arch = platform.machine() + arch = "amd64" if arch == "x86_64" else arch + Path("./dist/terranova").replace( + Path(f"./dist/terranova-{version}-{system}-{arch}") + ) + case "linux": + # Use cross-build to build both amd64 and arm64 versions. + container = Container() + for arch in ["amd64", "arm64"]: + platform_arch = f"linux/{arch}" + tag = f"{Constants.REGISTRY_URL}/terranova:{image_id}" + container.build_image(platform_arch, python_version, tag) + container_id = container.run_detached(platform_arch, tag) + container.copy_from( + container_id, + "/opt/terranova/dist/terranova", + local_dist_path / f"terranova-{version}-linux-{arch}", + ) + container.remove(container_id) + case _: + print(f"Unsupported system: {system}", file=sys.stderr) diff --git a/scripts/env.py b/scripts/tasks/env.py similarity index 83% rename from scripts/env.py rename to scripts/tasks/env.py index 8b4f17b..a5f5c64 100644 --- a/scripts/env.py +++ b/scripts/tasks/env.py @@ -14,9 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from scripts.utils import detect_pre_commit +from scripts.binds.pre_commit import PreCommit def configure() -> None: - pre_commit = detect_pre_commit() - pre_commit.args("install").inherit_out().exec() + """Install and configure pre-commit hooks.""" + PreCommit().install() diff --git a/scripts/generate.py b/scripts/tasks/generate.py similarity index 90% rename from scripts/generate.py rename to scripts/tasks/generate.py index 0a59e97..2e2887f 100644 --- a/scripts/generate.py +++ b/scripts/tasks/generate.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from scripts.utils import detect_pyinstaller +from scripts.binds.pyinstaller import PyInstaller def run() -> None: @@ -36,5 +36,4 @@ def run() -> None: "./bin/terranova", ] ) - pyinstaller = detect_pyinstaller() - pyinstaller.args(*args).inherit_out().exec() + PyInstaller().run(*args) diff --git a/scripts/lint.py b/scripts/tasks/lint.py similarity index 51% rename from scripts/lint.py rename to scripts/tasks/lint.py index 2163b79..eb76ada 100644 --- a/scripts/lint.py +++ b/scripts/tasks/lint.py @@ -15,27 +15,19 @@ # limitations under the License. # import sys -from io import StringIO +from scripts.binds.basedpyright import BasedPyright +from scripts.binds.git import Git +from scripts.binds.ruff import Ruff +from scripts.binds.uv import Uv +from scripts.utils import fatal from terranova.process import ErrorReturnCode -from scripts.utils import detect_basedpyright, detect_git, detect_ruff, detect_uv, fatal - - -def git_branch_delete(branch_name: str) -> None: - """Delete a git branch if exists.""" - try: - git = detect_git() - git.args("branch", "-D", branch_name).inherit_out().exec() - except ErrorReturnCode: - pass - def check_ruff() -> None: print("Checking codebase") try: - ruff = detect_ruff() - ruff.args("check", "src/terranova").inherit_out().exec() + Ruff().check("src/terranova") except ErrorReturnCode as err: # Forward exit code without traceback sys.exit(err.exit_code) @@ -44,8 +36,7 @@ def check_ruff() -> None: def check_basedpyright() -> None: print("Type checking codebase") try: - basedpyright = detect_basedpyright() - basedpyright.inherit_out().exec() + BasedPyright().check() except ErrorReturnCode as err: # Forward exit code without traceback sys.exit(err.exit_code) @@ -53,35 +44,27 @@ def check_basedpyright() -> None: def check_license_headers() -> None: print("Checking license headers") - git = detect_git() - - capture_stdout = StringIO() - git.args("rev-parse", "HEAD").stdout(capture_stdout).stderr(sys.stderr).exec() - head_commit_hash = capture_stdout.getvalue().strip() + git = Git() - capture_stdout = StringIO() - git.args("rev-parse", "--abbrev-ref", "HEAD").stdout(capture_stdout).exec() - current_branch_name = capture_stdout.getvalue().strip() + head_commit_hash = git.head() + current_branch_name = git.current_branch() branch_name = f"automation/lint-{head_commit_hash}" try: # Prepare the branch - git_branch_delete(branch_name) - git.args("checkout", "-b", branch_name).inherit().exec() + git.branch_delete(branch_name) + git.checkout_new_branch(branch_name) # Apply headers licence - uv = detect_uv() - uv.args("run", "poe", "project:license").inherit().exec() + Uv().run_poe("project:license") # Validate - capture_stdout = StringIO() - git.args("status", "-s").stdout(capture_stdout).stderr(sys.stderr).exec() - changes = capture_stdout.getvalue().strip() + changes = git.status_short() if changes: fatal(f"Apply headers license to:\n{changes}") finally: - git.args("checkout", current_branch_name).exec() - git_branch_delete(branch_name) + git.checkout(current_branch_name) + git.branch_delete(branch_name) def run() -> None: diff --git a/scripts/release.py b/scripts/tasks/release.py similarity index 71% rename from scripts/release.py rename to scripts/tasks/release.py index faa71f7..067e0a6 100644 --- a/scripts/release.py +++ b/scripts/tasks/release.py @@ -19,10 +19,12 @@ import sys from pathlib import Path +from scripts.binds.gh import Gh +from scripts.binds.git import Git +from scripts.binds.uv import Uv +from scripts.utils import Constants from terranova.process import ErrorReturnCode -from scripts.utils import Constants, detect_gh, detect_git, project_version - def __set_version(version: str) -> None: # Update app version @@ -84,59 +86,38 @@ def pre() -> None: # TODO: if you change the branch_name, please update the # condition at .github/workflows/ci.yml branch_name = f"feat/pre-release-v{release_version}" - git = detect_git() - git.args("checkout", "-b", branch_name).inherit_out().exec() + git = Git() + git.checkout_new_branch(branch_name) # Update all files __set_version(release_version) # Push release branch - git.args("add", "--all").inherit_out().exec() - ( - git.args( - "commit", "-m", f"release: terranova v{release_version}", "--no-verify" - ) - .inherit_out() - .exec() - ) - git.args("push", "origin", branch_name).inherit_out().exec() + git.add_all() + git.commit(f"release: terranova v{release_version}", no_verify=True) + git.push("origin", branch_name) # Create a PR - gh = detect_gh() - ( - gh.args("pr", "create", "--fill", "--base=main", f"--head={branch_name}") - .inherit_out() - .exec() - ) + Gh().pr_create(base="main", head=branch_name) def run() -> None: # Read project version - release_version = project_version() + release_version = Uv().project_version() # Create the release tag try: - git = detect_git() - git.args("tag", release_version).exec() - git.args("push", "origin", release_version).exec() + git = Git() + git.tag(release_version) + git.push("origin", release_version, inherit_out=False) except ErrorReturnCode: return print( f"The release `v{release_version}` already exists.", file=sys.stderr ) # Create the release - args = [ - "release", - "create", - "--generate-notes", - "--latest", - f"--title=terranova v{release_version}", - release_version, - ] binaries = [file.absolute().as_posix() for file in Path(".").glob("./terranova-*")] - args.extend(binaries) - gh = detect_gh() - gh.args(*args).inherit_out().exec() + Gh().release_create(release_version, f"terranova v{release_version}", binaries) def post() -> None: @@ -153,25 +134,16 @@ def post() -> None: # TODO: if you change the branch_name, please update the # condition at .github/workflows/ci.yml branch_name = f"feat/post-release-v{next_version}" - git = detect_git() - git.args("checkout", "-b", branch_name).inherit_out().exec() + git = Git() + git.checkout_new_branch(branch_name) # Update all files __set_version(next_version) # Push changes - git.args("add", "--all").inherit_out().exec() - ( - git.args("commit", "-m", "chore: prepare for next iteration", "--no-verify") - .inherit_out() - .exec() - ) - git.args("push", "origin", branch_name).inherit_out().exec() + git.add_all() + git.commit("chore: prepare for next iteration", no_verify=True) + git.push("origin", branch_name) # Create a PR - gh = detect_gh() - ( - gh.args("pr", "create", "--fill", "--base=main", f"--head={branch_name}") - .inherit_out() - .exec() - ) + Gh().pr_create(base="main", head=branch_name) diff --git a/scripts/utils.py b/scripts/utils.py index 5726abe..13348e6 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -15,14 +15,10 @@ # limitations under the License. # import os -import re import sys -from io import StringIO from pathlib import Path from typing import Final, NoReturn -from terranova.process import Command, CommandNotFound, EnvCmd - class Constants: """All constants""" @@ -41,133 +37,3 @@ def fatal(msg: str, err: Exception | None = None) -> NoReturn: if err: print(err, file=sys.stderr) sys.exit(1) - - -def detect_uv() -> Command: - """ - Try to detect uv. - - Returns: - a command if uv is detected. - """ - try: - return Command("uv") - except CommandNotFound: - fatal("`uv` isn't detected") - - -def detect_git() -> Command: - """ - Try to detect git. - Returns: - a command if git is detected. - """ - try: - return Command("git") - except CommandNotFound: - fatal("`git` isn't installed") - - -def detect_gh() -> Command: - """ - Try to detect gh. - Returns: - a command if gh is detected. - """ - try: - return Command("gh") - except CommandNotFound: - fatal("`gh` isn't installed") - - -def detect_ruff() -> Command: - """ - Try to detect ruff. - Returns: - a command if ruff is detected. - """ - try: - return Command("ruff") - except CommandNotFound: - fatal("`ruff` isn't installed") - - -def detect_basedpyright() -> Command: - """ - Try to detect basedpyright. - Returns: - a command if basedpyright is detected. - """ - try: - return Command("basedpyright") - except CommandNotFound: - fatal("`basedpyright` isn't installed") - - -def detect_pyinstaller() -> Command: - """ - Try to detect pyinstaller. - Returns: - a command if pyinstaller is detected. - """ - try: - return Command("pyinstaller") - except CommandNotFound: - fatal("`pyinstaller` isn't installed") - - -def detect_pre_commit() -> Command: - """ - Try to detect pre-commit. - Returns: - a command if pre-commit is detected. - """ - try: - return Command("pre-commit") - except CommandNotFound: - fatal("`pre-commit` isn't installed") - - -def project_version() -> str: - """ - Returns: - current project version. - """ - uv = detect_uv() - capture_stdout = StringIO() - ( - uv.args("pip", "show", "terranova") - .stdout(capture_stdout) - .stderr(sys.stderr) - .exec() - ) - match = re.search(r"Version: (.*)", capture_stdout.getvalue()) - if not match: - fatal("Failed to detect project version") - return match.group(1).replace(".dev0", "-dev").strip() - - -def container_backend() -> Command: - """ - Try to detect a container backend. - Either podman or docker. - - Returns: - a command if a backend is detected. - Raises: - CommandNotFound: if a suitable backend can't be found. - """ - cmd = None - env = EnvCmd.inherit() - for backend in ["docker", "podman"]: - try: - cmd = Command(backend) - except CommandNotFound: - continue - if "podman" == backend: - env.add({"BUILDAH_FORMAT": "docker"}) - break - - if not cmd: - raise CommandNotFound("Unable to find a suitable backend: docker or podman") - return cmd.env(env.build()) From baeead157156333ef465652da0c5b4480f850b08 Mon Sep 17 00:00:00 2001 From: amannocci Date: Tue, 14 Jul 2026 17:41:42 +0200 Subject: [PATCH 2/3] refactor: make PyInstaller interface more specific --- scripts/binds/pyinstaller.py | 24 ++++++++++++++++++++++-- scripts/tasks/build.py | 2 +- scripts/tasks/generate.py | 22 ++++------------------ 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/scripts/binds/pyinstaller.py b/scripts/binds/pyinstaller.py index dcf4633..2e95b8b 100644 --- a/scripts/binds/pyinstaller.py +++ b/scripts/binds/pyinstaller.py @@ -28,6 +28,26 @@ def __init__(self) -> None: except CommandNotFound as err: fatal("detect pyinstaller binary", err) - def run(self, *args: str) -> None: - """Run pyinstaller with the given arguments.""" + def build(self, spec: str) -> None: + """Build binary from a spec file.""" + self._cmd.args(spec).inherit_out().exec() + + def generate( + self, + exclude_modules: tuple[str, ...] = (), + hidden_imports: tuple[str, ...] = (), + add_data: tuple[tuple[str, str], ...] = (), + ) -> None: + """Generate pyinstaller config and build binary.""" + args = ["-n", "terranova", "--onefile", "--noconfirm", "--optimize=1"] + for exclude_module in exclude_modules: + args.extend(["--exclude-module", exclude_module]) + + for hidden_import in hidden_imports: + args.extend(["--hidden-import", hidden_import]) + + for src, dst in add_data: + args.extend(["--add-data", f"{src}:{dst}"]) + + args.append("./bin/terranova") self._cmd.args(*args).inherit_out().exec() diff --git a/scripts/tasks/build.py b/scripts/tasks/build.py index 8708725..e8476c2 100644 --- a/scripts/tasks/build.py +++ b/scripts/tasks/build.py @@ -42,7 +42,7 @@ def run() -> None: system = platform.system().lower() match system: case "darwin": - PyInstaller().run("terranova.spec") + PyInstaller().build("terranova.spec") arch = platform.machine() arch = "amd64" if arch == "x86_64" else arch Path("./dist/terranova").replace( diff --git a/scripts/tasks/generate.py b/scripts/tasks/generate.py index 2e2887f..68111a3 100644 --- a/scripts/tasks/generate.py +++ b/scripts/tasks/generate.py @@ -18,22 +18,8 @@ def run() -> None: - args = ["-n", "terranova", "--onefile", "--noconfirm", "--optimize=1"] - exclude_modules = () - for exclude_module in exclude_modules: - args.extend(["--exclude-module", exclude_module]) - - hidden_imports = () - for hidden_import in hidden_imports: - args.extend(["--hidden-import", hidden_import]) - - args.extend( - [ - "--add-data", - "src/terranova/schemas/:terranova/schemas/", - "--add-data", - "src/terranova/templates/:terranova/templates/", - "./bin/terranova", - ] + add_data = ( + ("src/terranova/schemas/", "terranova/schemas/"), + ("src/terranova/templates/", "terranova/templates/"), ) - PyInstaller().run(*args) + PyInstaller().generate(add_data=add_data) From ece53cdbd860f106197ed671f6c28d7da25cff2f Mon Sep 17 00:00:00 2001 From: amannocci Date: Tue, 14 Jul 2026 18:29:28 +0200 Subject: [PATCH 3/3] refactor: decentralize constants to individual files and add docstrings --- scripts/binds/git.py | 14 ++++---------- scripts/tasks/build.py | 17 ++++++++++------- scripts/tasks/generate.py | 1 + scripts/tasks/lint.py | 4 ++++ scripts/tasks/release.py | 27 +++++++++++++++++---------- scripts/utils.py | 15 +-------------- 6 files changed, 37 insertions(+), 41 deletions(-) diff --git a/scripts/binds/git.py b/scripts/binds/git.py index 99dd58e..4332e5e 100644 --- a/scripts/binds/git.py +++ b/scripts/binds/git.py @@ -49,12 +49,9 @@ def current_branch(self) -> str: self._cmd.args("rev-parse", "--abbrev-ref", "HEAD").stdout(capture).exec() return capture.getvalue().strip() - def checkout(self, name: str, inherit_out: bool = False) -> None: + def checkout(self, name: str) -> None: """Switch to an existing branch.""" - cmd = self._cmd.args("checkout", name) - if inherit_out: - cmd.inherit_out() - cmd.exec() + self._cmd.args("checkout", name).inherit_out().exec() def checkout_new_branch(self, name: str) -> None: """Create and switch to a new branch.""" @@ -71,12 +68,9 @@ def commit(self, message: str, no_verify: bool = False) -> None: args.append("--no-verify") self._cmd.args(*args).inherit_out().exec() - def push(self, remote: str, ref: str, inherit_out: bool = True) -> None: + def push(self, remote: str, ref: str) -> None: """Push a ref to a remote.""" - cmd = self._cmd.args("push", remote, ref) - if inherit_out: - cmd.inherit_out() - cmd.exec() + self._cmd.args("push", remote, ref).inherit_out().exec() def tag(self, name: str) -> None: """ diff --git a/scripts/tasks/build.py b/scripts/tasks/build.py index e8476c2..4b70304 100644 --- a/scripts/tasks/build.py +++ b/scripts/tasks/build.py @@ -14,19 +14,24 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import os import platform import sys from pathlib import Path from time import time +from typing import Final from scripts.binds.container import Container from scripts.binds.git import Git from scripts.binds.pyinstaller import PyInstaller from scripts.binds.uv import Uv -from scripts.utils import Constants + +DIST_DIR: Final[Path] = (Path(__file__).parent.parent.parent / "dist").absolute() +REGISTRY_URL: str = os.getenv("REGISTRY_URL", "local.dev") def run() -> None: + """Build standalone terranova binaries for the current platform(s).""" commit_hash_short = Git().short_head() current_time_epoch = int(time()) version = Uv().project_version() @@ -35,9 +40,7 @@ def run() -> None: image_id = f"{version}-{current_time_epoch}-{commit_hash_short}" # Create dist dir - local_dist_path = Path("dist") - local_dist_path.mkdir(parents=True, exist_ok=False) - local_dist_path = local_dist_path.absolute() + DIST_DIR.mkdir(parents=True, exist_ok=False) system = platform.system().lower() match system: @@ -45,7 +48,7 @@ def run() -> None: PyInstaller().build("terranova.spec") arch = platform.machine() arch = "amd64" if arch == "x86_64" else arch - Path("./dist/terranova").replace( + (DIST_DIR / "terranova").replace( Path(f"./dist/terranova-{version}-{system}-{arch}") ) case "linux": @@ -53,13 +56,13 @@ def run() -> None: container = Container() for arch in ["amd64", "arm64"]: platform_arch = f"linux/{arch}" - tag = f"{Constants.REGISTRY_URL}/terranova:{image_id}" + tag = f"{REGISTRY_URL}/terranova:{image_id}" container.build_image(platform_arch, python_version, tag) container_id = container.run_detached(platform_arch, tag) container.copy_from( container_id, "/opt/terranova/dist/terranova", - local_dist_path / f"terranova-{version}-linux-{arch}", + DIST_DIR / f"terranova-{version}-linux-{arch}", ) container.remove(container_id) case _: diff --git a/scripts/tasks/generate.py b/scripts/tasks/generate.py index 68111a3..0b4486d 100644 --- a/scripts/tasks/generate.py +++ b/scripts/tasks/generate.py @@ -18,6 +18,7 @@ def run() -> None: + """Generate PyInstaller configuration with included data files.""" add_data = ( ("src/terranova/schemas/", "terranova/schemas/"), ("src/terranova/templates/", "terranova/templates/"), diff --git a/scripts/tasks/lint.py b/scripts/tasks/lint.py index eb76ada..aa916b0 100644 --- a/scripts/tasks/lint.py +++ b/scripts/tasks/lint.py @@ -25,6 +25,7 @@ def check_ruff() -> None: + """Check codebase formatting and style with ruff.""" print("Checking codebase") try: Ruff().check("src/terranova") @@ -34,6 +35,7 @@ def check_ruff() -> None: def check_basedpyright() -> None: + """Type check codebase with basedpyright.""" print("Type checking codebase") try: BasedPyright().check() @@ -43,6 +45,7 @@ def check_basedpyright() -> None: def check_license_headers() -> None: + """Verify and apply Apache license headers to all project files.""" print("Checking license headers") git = Git() @@ -68,6 +71,7 @@ def check_license_headers() -> None: def run() -> None: + """Run all lint checks: formatting, type checking, and license headers.""" check_ruff() check_basedpyright() check_license_headers() diff --git a/scripts/tasks/release.py b/scripts/tasks/release.py index 067e0a6..001506f 100644 --- a/scripts/tasks/release.py +++ b/scripts/tasks/release.py @@ -18,21 +18,25 @@ import re import sys from pathlib import Path +from typing import Final from scripts.binds.gh import Gh from scripts.binds.git import Git from scripts.binds.uv import Uv -from scripts.utils import Constants from terranova.process import ErrorReturnCode +PYPROJECT_PATH: Final[Path] = Path("pyproject.toml") +TERRANOVA_INIT_PATH: Final[Path] = Path("./src/terranova/__init__.py") + def __set_version(version: str) -> None: + """Update version in __init__.py and pyproject.toml.""" # Update app version try: - data = Constants.TERRANOVA_INIT_PATH.read_text() + data = TERRANOVA_INIT_PATH.read_text() except Exception as err: print( - f"The `{Constants.TERRANOVA_INIT_PATH.as_posix()}` can't be read", + f"The `{TERRANOVA_INIT_PATH.as_posix()}` can't be read", file=sys.stderr, ) raise err @@ -41,36 +45,37 @@ def __set_version(version: str) -> None: r"__version__ = \"(.*)\"", f'__version__ = "{version}"', data, count=1 ) try: - Constants.TERRANOVA_INIT_PATH.write_text(data) + TERRANOVA_INIT_PATH.write_text(data) except Exception as err: print( - f"The `{Constants.TERRANOVA_INIT_PATH.as_posix()}` file can't be written", + f"The `{TERRANOVA_INIT_PATH.as_posix()}` file can't be written", file=sys.stderr, ) raise err # Update project version try: - data = Constants.PYPROJECT_PATH.read_text() + data = PYPROJECT_PATH.read_text() except Exception as err: print( - f"The `{Constants.PYPROJECT_PATH.as_posix()}` can't be read", + f"The `{PYPROJECT_PATH.as_posix()}` can't be read", file=sys.stderr, ) raise err data = re.sub(r"version = \"(.+)\"", f'version = "{version}"', data, count=1) try: - Constants.PYPROJECT_PATH.write_text(data) + PYPROJECT_PATH.write_text(data) except Exception as err: print( - f"The `{Constants.PYPROJECT_PATH.as_posix()}` file can't be written", + f"The `{PYPROJECT_PATH.as_posix()}` file can't be written", file=sys.stderr, ) raise err def pre() -> None: + """Create a release branch and PR to prepare for release.""" # Ensure we have inputs release_version = os.getenv("RELEASE_VERSION") if not release_version: @@ -102,6 +107,7 @@ def pre() -> None: def run() -> None: + """Create a release tag and GitHub release with binaries.""" # Read project version release_version = Uv().project_version() @@ -109,7 +115,7 @@ def run() -> None: try: git = Git() git.tag(release_version) - git.push("origin", release_version, inherit_out=False) + git.push("origin", release_version) except ErrorReturnCode: return print( f"The release `v{release_version}` already exists.", file=sys.stderr @@ -121,6 +127,7 @@ def run() -> None: def post() -> None: + """Prepare for the next development iteration after release.""" # Ensure we have inputs next_version = os.getenv("NEXT_VERSION") if not next_version: diff --git a/scripts/utils.py b/scripts/utils.py index 13348e6..a3cf14b 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -14,21 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import os import sys -from pathlib import Path -from typing import Final, NoReturn - - -class Constants: - """All constants""" - - # pylint: disable=R0903 - ENCODING_UTF_8: Final[str] = "utf-8" - PYPROJECT_PATH: Final[Path] = Path("pyproject.toml") - PYRIGHTCONFIG_PATH: Final[Path] = Path("pyrightconfig.json") - REGISTRY_URL: str = os.getenv("REGISTRY_URL", "local.dev") - TERRANOVA_INIT_PATH: Final[Path] = Path("./src/terranova/__init__.py") +from typing import NoReturn def fatal(msg: str, err: Exception | None = None) -> NoReturn: