diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 803d731..054a83a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,3 +107,20 @@ jobs: # run: uv sync --locked --all-extras --dev # - name: Run Integrationtests # run: uv run pytest tests/integration + + package_ourself: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Install the project + run: uv sync --locked --all-extras --dev + - name: Run Debmagic build on ourself + run: uv run debmagic build --driver=docker \ No newline at end of file diff --git a/debian/control b/debian/control index a36695f..14c0759 100644 --- a/debian/control +++ b/debian/control @@ -5,10 +5,15 @@ Uploaders: Jonas Jelten , Build-Depends: debhelper-compat (= 13), + dh-python, + python3-all, + pybuild-plugin-pyproject, + python3-setuptools, python3-debian Rules-Requires-Root: no X-Style: black Standards-Version: 4.7.2 +X-Python-Version: >= 3.12 Homepage: https://github.com/SFTtech/debmagic Vcs-Git: https://github.com/SFTtech/debmagic.git Vcs-Browser: https://github.com/SFTtech/debmagic @@ -17,7 +22,8 @@ Package: debmagic Architecture: all Depends: python3-debian, + ${misc:Depends}, ${python3:Depends} Multi-Arch: foreign -Description: TODO - TODO +Description: Debian build instructions written in Python. + Explicit is better than implicit. diff --git a/debian/debmagic.1 b/debian/debmagic.1 new file mode 100644 index 0000000..4550f3e --- /dev/null +++ b/debian/debmagic.1 @@ -0,0 +1,43 @@ +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. +.TH DEBMAGIC "1" "November 2025" "debmagic 0.1.0" "User Commands" +.SH NAME +debmagic \- manual page for debmagic 0.1.0 +.SH DESCRIPTION +usage: debmagic [\-h] [\-\-version] {help,version,debuild,build} ... +.PP +Debmagic +.SS "positional arguments:" +.IP +{help,version,debuild,build} +.TP +help +Show this help page and exit +.TP +version +Print the version information and exit +.TP +debuild +Simply run debuild in the current working directory +.TP +build +Buidl a debian package with the selected +containerization driver +.SS "options:" +.TP +\fB\-h\fR, \fB\-\-help\fR +show this help message and exit +.TP +\fB\-\-version\fR +show program's version number and exit +.SH "SEE ALSO" +The full documentation for +.B debmagic +is maintained as a Texinfo manual. If the +.B info +and +.B debmagic +programs are properly installed at your site, the command +.IP +.B info debmagic +.PP +should give you access to the complete manual. diff --git a/debian/rules b/debian/rules index 863a4e6..05d1894 100755 --- a/debian/rules +++ b/debian/rules @@ -6,8 +6,13 @@ from pathlib import Path repo_root = Path(__file__).parent.parent / "src" sys.path.append(str(repo_root)) -from debmagic.v1 import package, python +from debmagic.v0 import package +from debmagic.v0 import dh as dh_mod -package( - preset=python, +dh = dh_mod.Preset(dh_args=["--with", "python3", "--buildsystem=pybuild"]) + +pkg = package( + preset=[dh], ) + +pkg.pack() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 56ed6e9..0606418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,8 @@ name = "debmagic" version = "0.1.0" description = "build debian packages" -license = { file = "LICENSE" } +license = "GPL-2.0-or-later" +license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.12" classifiers = ["Programming Language :: Python :: 3"] @@ -31,11 +32,19 @@ exclude_gitignore = true [tool.ruff] line-length = 120 target-version = "py312" -extend-exclude = [".idea", ".mypy_cache", ".venv*", "docs", "debian", "__pycache__", "*.egg_info"] +extend-exclude = [ + ".idea", + ".mypy_cache", + ".venv*", + "docs", + "debian", + "__pycache__", + "*.egg_info", +] [tool.ruff.lint] select = ["E", "W", "F", "I", "C", "N", "PL", "RUF", "I001"] -ignore = ["E722", "PLR2004", "PLR0912", "PLR5501", "PLC0415"] +ignore = ["E722", "PLR2004", "PLR0912", "PLR5501", "PLC0415", "PLR0911"] mccabe.max-complexity = 25 pylint.max-args = 10 diff --git a/src/debmagic/__main__.py b/src/debmagic/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/src/debmagic/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/src/debmagic/_build_driver/__init__.py b/src/debmagic/_build_driver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/debmagic/_build_driver/build.py b/src/debmagic/_build_driver/build.py new file mode 100644 index 0000000..5126893 --- /dev/null +++ b/src/debmagic/_build_driver/build.py @@ -0,0 +1,99 @@ +import re +import shutil +from pathlib import Path + +from debmagic._build_driver.driver_docker import BuildDriverDocker +from debmagic._build_driver.driver_lxd import BuildDriverLxd +from debmagic._build_driver.driver_none import BuildDriverNone + +from .common import BuildConfig, BuildDriver, BuildDriverType + +DEBMAGIC_TEMP_BUILD_PARENT_DIR = Path("/tmp/debmagic") + + +def _create_driver(build_driver: BuildDriverType, config: BuildConfig) -> BuildDriver: + match build_driver: + case "docker": + return BuildDriverDocker.create(config=config) + case "lxd": + return BuildDriverLxd.create(config=config) + case "none": + return BuildDriverNone.create(config=config) + + +def _ignore_patterns_from_gitignore(gitignore_path: Path): + if not gitignore_path.is_file(): + return None + + contents = gitignore_path.read_text().strip().splitlines() + relevant_lines = filter(lambda line: not re.match(r"\s*#.*", line) and line.strip(), contents) + return shutil.ignore_patterns(*relevant_lines) + + +def _prepare_build_env(source_dir: Path, output_dir: Path, dry_run: bool) -> BuildConfig: + package_name = "debmagic" # TODO + package_version = "0.1.0" # TODO + + package_identifier = f"{package_name}-{package_version}" + build_root = DEBMAGIC_TEMP_BUILD_PARENT_DIR / package_identifier + if build_root.exists(): + shutil.rmtree(build_root) + + config = BuildConfig( + package_identifier=package_identifier, + source_dir=source_dir, + output_dir=output_dir, + build_root_dir=build_root, + distro="debian", + distro_version="trixie", + dry_run=dry_run, + sign_package=False, + ) + + # prepare build environment, create the build directory structure, copy the sources + config.create_dirs() + source_ignore_pattern = _ignore_patterns_from_gitignore(source_dir / ".gitignore") + shutil.copytree(config.source_dir, config.build_source_dir, dirs_exist_ok=True, ignore=source_ignore_pattern) + + return config + + +def _copy_file_if_exists(source: Path, glob: str, dest: Path): + for file in source.glob(glob): + if file.is_dir(): + shutil.copytree(file, dest) + elif file.is_file(): + shutil.copy(file, dest) + else: + raise NotImplementedError("Don't support anything besides files and directories") + + +def build(build_driver: BuildDriverType, source_dir: Path, output_dir: Path, dry_run: bool = False): + config = _prepare_build_env(source_dir=source_dir, output_dir=output_dir, dry_run=dry_run) + + driver = _create_driver(build_driver, config) + try: + driver.run_command(["apt-get", "-y", "build-dep", "."], cwd=config.build_source_dir, requires_root=True) + driver.run_command(["dpkg-buildpackage", "-us", "-uc", "-ui", "-nc", "-b"], cwd=config.build_source_dir) + if config.sign_package: + pass + # SIGN .changes and .dsc files + # changes = *.changes / *.dsc + # driver.run_command(["debsign", opts, changes], cwd=config.source_dir) + # driver.run_command(["debrsign", opts, username, changes], cwd=config.source_dir) + + # TODO: copy packages to output directory + _copy_file_if_exists(source=config.build_source_dir / "..", glob="*.deb", dest=config.output_dir) + _copy_file_if_exists(source=config.build_source_dir / "..", glob="*.buildinfo", dest=config.output_dir) + _copy_file_if_exists(source=config.build_source_dir / "..", glob="*.changes", dest=config.output_dir) + _copy_file_if_exists(source=config.build_source_dir / "..", glob="*.dsc", dest=config.output_dir) + except Exception as e: + print(e) + print( + "Something failed during building -" + " dropping into interactive shell in build environment for easier debugging" + ) + driver.drop_into_shell() + raise e + finally: + driver.cleanup() diff --git a/src/debmagic/_build_driver/common.py b/src/debmagic/_build_driver/common.py new file mode 100644 index 0000000..0ae4c8d --- /dev/null +++ b/src/debmagic/_build_driver/common.py @@ -0,0 +1,62 @@ +import abc +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Self, Sequence + +BuildDriverType = Literal["docker"] | Literal["lxd"] | Literal["none"] +SUPPORTED_BUILD_DRIVERS: list[BuildDriverType] = ["docker", "none"] + + +class BuildError(RuntimeError): + pass + + +@dataclass +class BuildConfig: + package_identifier: str + source_dir: Path + output_dir: Path + dry_run: bool + distro_version: str # e.g. trixie + distro: str # e.g. debian + sign_package: bool # TODO: figure out if this is the right place + + # build paths + build_root_dir: Path + + @property + def build_work_dir(self) -> Path: + return self.build_root_dir / "work" + + @property + def build_temp_dir(self) -> Path: + return self.build_root_dir / "temp" + + @property + def build_source_dir(self) -> Path: + return self.build_work_dir / self.package_identifier + + def create_dirs(self): + self.output_dir.mkdir(exist_ok=True, parents=True) + self.build_work_dir.mkdir(exist_ok=True, parents=True) + self.build_temp_dir.mkdir(exist_ok=True, parents=True) + self.build_source_dir.mkdir(exist_ok=True, parents=True) + + +class BuildDriver: + @classmethod + @abc.abstractmethod + def create(cls, config: BuildConfig) -> Self: + pass + + @abc.abstractmethod + def run_command(self, cmd: Sequence[str | Path], cwd: Path | None = None, requires_root: bool = False): + pass + + @abc.abstractmethod + def cleanup(self): + pass + + @abc.abstractmethod + def drop_into_shell(self): + pass diff --git a/src/debmagic/_build_driver/driver_docker.py b/src/debmagic/_build_driver/driver_docker.py new file mode 100644 index 0000000..cb9d504 --- /dev/null +++ b/src/debmagic/_build_driver/driver_docker.py @@ -0,0 +1,100 @@ +import uuid +from pathlib import Path +from typing import Self, Sequence + +from debmagic._build_driver.common import BuildConfig, BuildDriver, BuildError +from debmagic._utils import run_cmd, run_cmd_in_foreground + +BUILD_DIR_IN_CONTAINER = Path("/debmagic") + +DOCKERFILE_TEMPLATE = f""" +FROM docker.io/{{distro}}:{{distro_version}} + +RUN apt-get update && apt-get -y install dpkg-dev + +RUN mkdir -p {BUILD_DIR_IN_CONTAINER} +ENTRYPOINT ["sleep", "infinity"] +""" + + +class BuildDriverDocker(BuildDriver): + def __init__(self, config: BuildConfig, container_name: str): + self._config = config + + self._container_name = container_name + + def _translate_path_in_container(self, path_in_source: Path) -> Path: + if not path_in_source.is_relative_to(self._config.build_root_dir): + raise BuildError("Cannot run in a path not relative to the original source directory") + rel = path_in_source.relative_to(self._config.build_root_dir) + return BUILD_DIR_IN_CONTAINER / rel + + @classmethod + def create(cls, config: BuildConfig) -> Self: + formatted_dockerfile = DOCKERFILE_TEMPLATE.format( + distro=config.distro, + distro_version=config.distro_version, + ) + + dockerfile_path = config.build_temp_dir / "Dockerfile" + dockerfile_path.write_text(formatted_dockerfile) + + docker_image_name = str(uuid.uuid4()) + ret = run_cmd( + [ + "docker", + "build", + "--tag", + docker_image_name, + "-f", + dockerfile_path, + config.build_temp_dir, + ], + dry_run=config.dry_run, + check=False, + ) + if ret.returncode != 0: + raise BuildError("Error creating docker image for build") + + docker_container_name = str(uuid.uuid4()) + ret = run_cmd( + [ + "docker", + "run", + "--detach", + "--name", + docker_container_name, + "--mount", + f"type=bind,src={config.build_root_dir},dst={BUILD_DIR_IN_CONTAINER}", + docker_image_name, + ], + dry_run=config.dry_run, + check=False, + ) + if ret.returncode != 0: + raise BuildError("Error creating docker image for build") + + instance = cls(config=config, container_name=docker_container_name) + return instance + + def run_command(self, cmd: Sequence[str | Path], cwd: Path | None = None, requires_root: bool = False): + del requires_root # we assume to always be root in the container + + if cwd: + cwd = self._translate_path_in_container(cwd) + cwd_args: list[str | Path] = ["--workdir", cwd] + else: + cwd_args = [] + + ret = run_cmd(["docker", "exec", *cwd_args, self._container_name, *cmd], dry_run=self._config.dry_run) + if ret.returncode != 0: + raise BuildError("Error building package") + + def cleanup(self): + run_cmd(["docker", "rm", "-f", self._container_name], dry_run=self._config.dry_run) + + def drop_into_shell(self): + if not self._config.dry_run: + run_cmd_in_foreground( + ["docker", "exec", "--interactive", "--tty", self._container_name, "/usr/bin/env", "bash"] + ) diff --git a/src/debmagic/_build_driver/driver_lxd.py b/src/debmagic/_build_driver/driver_lxd.py new file mode 100644 index 0000000..44aebef --- /dev/null +++ b/src/debmagic/_build_driver/driver_lxd.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import Sequence + +from debmagic._build_driver.common import BuildConfig, BuildDriver + + +class BuildDriverLxd(BuildDriver): + @classmethod + def create(cls, config: BuildConfig): + return cls() + + def run_command(self, cmd: Sequence[str | Path], cwd: Path | None = None, requires_root: bool = False): + raise NotImplementedError() + + def cleanup(self): + raise NotImplementedError() + + def drop_into_shell(self): + raise NotImplementedError() diff --git a/src/debmagic/_build_driver/driver_none.py b/src/debmagic/_build_driver/driver_none.py new file mode 100644 index 0000000..a4f62d0 --- /dev/null +++ b/src/debmagic/_build_driver/driver_none.py @@ -0,0 +1,26 @@ +import os +from pathlib import Path +from typing import Sequence + +from debmagic._build_driver.common import BuildConfig, BuildDriver +from debmagic._utils import run_cmd, run_cmd_in_foreground + + +class BuildDriverNone(BuildDriver): + def __init__(self, config: BuildConfig) -> None: + self._config = config + + @classmethod + def create(cls, config: BuildConfig): + return cls(config=config) + + def run_command(self, cmd: Sequence[str | Path], cwd: Path | None = None, requires_root: bool = False): + if requires_root and not os.getuid() == 0: + cmd = ["sudo", *cmd] + run_cmd(cmd=cmd, dry_run=self._config.dry_run, cwd=cwd) + + def cleanup(self): + pass + + def drop_into_shell(self): + run_cmd_in_foreground(["/usr/bin/env", "bash"]) diff --git a/src/debmagic/_build_stage.py b/src/debmagic/_build_stage.py index a7fd29d..64aad14 100644 --- a/src/debmagic/_build_stage.py +++ b/src/debmagic/_build_stage.py @@ -2,6 +2,7 @@ class BuildStage(StrEnum): + clean = "clean" prepare = "prepare" configure = "configure" build = "build" diff --git a/src/debmagic/_module/dh.py b/src/debmagic/_module/dh.py index bf58875..69718f7 100644 --- a/src/debmagic/_module/dh.py +++ b/src/debmagic/_module/dh.py @@ -32,10 +32,15 @@ class DHSequenceID(StrEnum): class Preset(PresetBase): - def __init__(self, dh_invocation: str | None = None): - if not dh_invocation: - dh_invocation = "dh" - self._dh_invocation: str = dh_invocation + def __init__(self, dh_args: list[str] | str | None = None): + self._dh_args: list[str] + if dh_args is None: + self._dh_args = [] + elif isinstance(dh_args, str): + self._dh_args = shlex.split(dh_args) + else: + self._dh_args = dh_args + self._overrides: dict[str, DHOverride] = {} self._initialized = False @@ -52,7 +57,7 @@ def __init__(self, dh_invocation: str | None = None): def initialize(self, src_pkg: SourcePackage) -> None: # get all steps the dh sequence would do - self._populate_stages(self._dh_invocation, base_dir=src_pkg.base_dir) + self._populate_stages(self._dh_args, base_dir=src_pkg.base_dir) self._initialized = True def clean(self, build: Build): @@ -97,7 +102,7 @@ def _run_dh_seq_cmds(self, build: Build, seq_cmds: list[str]) -> None: else: build.cmd(cmd, cwd=build.source_dir) - def _populate_stages(self, dh_invocation: str, base_dir: Path) -> None: + def _populate_stages(self, dh_args: list[str], base_dir: Path) -> None: """ split up the dh sequences into debmagic's stages. this involves guessing, since dh only has "build" (=configure, build, test) @@ -106,10 +111,10 @@ def _populate_stages(self, dh_invocation: str, base_dir: Path) -> None: if you have a better idea how to map dh sequences to debmagic's stages, please tell us. """ ## clean, which is 1:1 fortunately - self._clean_seq = self._get_dh_seq(base_dir, dh_invocation, DHSequenceID.clean) + self._clean_seq = self._get_dh_seq(base_dir, dh_args, DHSequenceID.clean) ## untangle "build" to configure & build & test - build_seq_raw = self._get_dh_seq(base_dir, dh_invocation, DHSequenceID.build) + build_seq_raw = self._get_dh_seq(base_dir, dh_args, DHSequenceID.build) if build_seq_raw[-1] != "create-stamp debian/debhelper-build-stamp": raise RuntimeError("build stamp creation line missing from dh build sequence") build_seq = build_seq_raw[:-1] # remove that stamp line @@ -128,9 +133,9 @@ def _populate_stages(self, dh_invocation: str, base_dir: Path) -> None: self._test_seq = build_seq[auto_test_idx:] ## untangle "binary" to install & package - install_seq = self._get_dh_seq(base_dir, dh_invocation, DHSequenceID.install) + install_seq = self._get_dh_seq(base_dir, dh_args, DHSequenceID.install) self._install_seq = list_strip_head(install_seq, build_seq_raw) - binary_seq = self._get_dh_seq(base_dir, dh_invocation, DHSequenceID.binary) + binary_seq = self._get_dh_seq(base_dir, dh_args, DHSequenceID.binary) self._package_seq = list_strip_head(binary_seq, install_seq) # register all sequence items for validity checks @@ -147,9 +152,8 @@ def _populate_stages(self, dh_invocation: str, base_dir: Path) -> None: cmd_id = cmd[0] self._seq_ids.add(cmd_id) - def _get_dh_seq(self, base_dir: Path, dh_invocation: str, seq: DHSequenceID) -> list[str]: - dh_base_cmd = shlex.split(dh_invocation) - cmd = [*dh_base_cmd, str(seq), "--no-act"] + def _get_dh_seq(self, base_dir: Path, dh_args: list[str], seq: DHSequenceID) -> list[str]: + cmd = ["dh", str(seq), "--no-act", *dh_args] proc = run_cmd(cmd, cwd=base_dir, capture_output=True, text=True) lines = proc.stdout.splitlines() return [line.strip() for line in lines] diff --git a/src/debmagic/_package.py b/src/debmagic/_package.py index 3c4e20e..039705f 100644 --- a/src/debmagic/_package.py +++ b/src/debmagic/_package.py @@ -43,7 +43,9 @@ def _parse_args(custom_functions: dict[str, CustomFunction] = {}): common_cli = argparse.ArgumentParser(add_help=False) common_cli.add_argument( - "--dry-run", action="store_true", help="don't actually run anything that changes the system/package state" + "--dry-run", + action="store_true", + help="don't actually run anything that changes the system/package state", ) # debian-required "targets": @@ -186,7 +188,7 @@ def pack(self): flags=self.buildflags, parallel=multiprocessing.cpu_count(), # TODO prefix=Path("/usr"), # TODO - dry_run=args.__dict__.get("dry_run"), # TODO + dry_run=args.dry_run, ) match args.operation: @@ -228,7 +230,8 @@ def pack(self): func_args = {k: vars(args)[k] for k in func.args.keys()} func.fun(**func_args) else: - cli.print_help(f"call to unknown operation {args.operation!r}") + print(f"call to unknown operation {args.operation!r}\n") + cli.print_help() cli.exit(1) diff --git a/src/debmagic/_preset.py b/src/debmagic/_preset.py index a10a427..138cf02 100644 --- a/src/debmagic/_preset.py +++ b/src/debmagic/_preset.py @@ -77,6 +77,8 @@ def _get_member(obj: type[T], stage: BuildStage) -> _PresetBuildStepClassMethod: def _get_member(obj: T | type[T], stage: BuildStage) -> BuildStep | _PresetBuildStepClassMethod: match stage: + case BuildStage.clean: + return obj.clean case BuildStage.prepare: return obj.prepare case BuildStage.configure: diff --git a/src/debmagic/_utils.py b/src/debmagic/_utils.py index 680b696..f79ae5b 100644 --- a/src/debmagic/_utils.py +++ b/src/debmagic/_utils.py @@ -1,9 +1,12 @@ import io +import os import re import shlex +import signal import subprocess import sys -from typing import Sequence, TypeVar +from pathlib import Path +from typing import Callable, Sequence, TypeVar class Namespace: @@ -37,17 +40,17 @@ def get(self, key): def run_cmd( - cmd: Sequence[str] | str, + cmd: Sequence[str | Path] | str, check: bool = True, dry_run: bool = False, **kwargs, ) -> subprocess.CompletedProcess: - cmd_args: Sequence[str] | str = cmd + cmd_args: Sequence[str | Path] | str = cmd cmd_pretty: str if kwargs.get("shell"): if not isinstance(cmd, str): - cmd_args = shlex.join(cmd) + cmd_args = shlex.join([str(x) for x in cmd]) else: if isinstance(cmd, str): cmd_args = shlex.split(cmd) @@ -55,7 +58,7 @@ def run_cmd( if isinstance(cmd, str): cmd_pretty = cmd else: - cmd_pretty = shlex.join(cmd_args) + cmd_pretty = shlex.join([str(x) for x in cmd_args]) print(f"debmagic: {cmd_pretty}") @@ -70,6 +73,90 @@ def run_cmd( return ret +def run_cmd_in_foreground(args: Sequence[str | Path], **kwargs): + """ + the "correct" way of spawning a new subprocess: + signals like C-c must only go + to the child process, and not to this python. + + the args are the same as subprocess.Popen + + returns Popen().wait() value + + Some side-info about "how ctrl-c works": + https://unix.stackexchange.com/a/149756/1321 + + fun fact: this function took a whole night + to be figured out. + """ + cmd_pretty = shlex.join([str(x) for x in args]) + print(f"debmagic[fg]: {cmd_pretty}") + + # import here to only use the dependency if really necessary (not available on Windows) + import termios + + old_pgrp = os.tcgetpgrp(sys.stdin.fileno()) + old_attr = termios.tcgetattr(sys.stdin.fileno()) + + user_preexec_fn: Callable | None = kwargs.pop("preexec_fn", None) + + def new_pgid(): + if user_preexec_fn: + user_preexec_fn() + + # set a new process group id + os.setpgid(os.getpid(), os.getpid()) + + # generally, the child process should stop itself + # before exec so the parent can set its new pgid. + # (setting pgid has to be done before the child execs). + # however, Python 'guarantee' that `preexec_fn` + # is run before `Popen` returns. + # this is because `Popen` waits for the closure of + # the error relay pipe '`errpipe_write`', + # which happens at child's exec. + # this is also the reason the child can't stop itself + # in Python's `Popen`, since the `Popen` call would never + # terminate then. + # `os.kill(os.getpid(), signal.SIGSTOP)` + + try: + # fork the child + child = subprocess.Popen(args, preexec_fn=new_pgid, **kwargs) # noqa: PLW1509 + + # we can't set the process group id from the parent since the child + # will already have exec'd. and we can't SIGSTOP it before exec, + # see above. + # `os.setpgid(child.pid, child.pid)` + + # set the child's process group as new foreground + os.tcsetpgrp(sys.stdin.fileno(), child.pid) + # revive the child, + # because it may have been stopped due to SIGTTOU or + # SIGTTIN when it tried using stdout/stdin + # after setpgid was called, and before we made it + # forward process by tcsetpgrp. + os.kill(child.pid, signal.SIGCONT) + + # wait for the child to terminate + ret = child.wait() + + finally: + # we have to mask SIGTTOU because tcsetpgrp + # raises SIGTTOU to all current background + # process group members (i.e. us) when switching tty's pgrp + # it we didn't do that, we'd get SIGSTOP'd + hdlr = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + # make us tty's foreground again + os.tcsetpgrp(sys.stdin.fileno(), old_pgrp) + # now restore the handler + signal.signal(signal.SIGTTOU, hdlr) + # restore terminal attributes + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attr) + + return ret + + def disable_output_buffer(): # always flush output sys.stdout = io.TextIOWrapper(open(sys.stdout.fileno(), "wb", 0), write_through=True) diff --git a/src/debmagic/_version.py b/src/debmagic/_version.py new file mode 100644 index 0000000..1cf6267 --- /dev/null +++ b/src/debmagic/_version.py @@ -0,0 +1 @@ +VERSION = "0.1.0" diff --git a/src/debmagic/cli.py b/src/debmagic/cli.py index 67b4f56..0a45d23 100644 --- a/src/debmagic/cli.py +++ b/src/debmagic/cli.py @@ -1,14 +1,55 @@ import argparse +from pathlib import Path +from debmagic._build_driver.build import build as build_driver_build +from debmagic._build_driver.common import SUPPORTED_BUILD_DRIVERS from debmagic._utils import run_cmd +from debmagic._version import VERSION -def parse_args(): - parser = argparse.ArgumentParser(description="Debmagic") - return parser.parse_args() +def _parse_args(): + cli = argparse.ArgumentParser(description="Debmagic") + sp = cli.add_subparsers(dest="operation") + + cli.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") + sp.add_parser("help", help="Show this help page and exit") + sp.add_parser("version", help="Print the version information and exit") + + common_cli = argparse.ArgumentParser(add_help=False) + common_cli.add_argument( + "--dry-run", action="store_true", help="don't actually run anything that changes the system/package state" + ) + + sp.add_parser("debuild", parents=[common_cli], help="Simply run debuild in the current working directory") + build_cli = sp.add_parser( + "build", parents=[common_cli], help="Build a debian package with the selected containerization driver" + ) + build_cli.add_argument("--driver", choices=SUPPORTED_BUILD_DRIVERS, required=True) + build_cli.add_argument("-s", "--source-dir", type=Path, default=Path.cwd()) + build_cli.add_argument("-o", "--output-dir", type=Path, default=Path.cwd()) + + sp.add_parser("check", parents=[common_cli], help="Run linters (e.g. lintian)") + + sp.add_parser("shell", parents=[common_cli], help="Attach a shell to a running debmagic build") + + sp.add_parser("test", parents=[common_cli], help="Run package tests") + + return cli, cli.parse_args() def main(): - # args = parse_args() - # just call debuild for now - run_cmd(["debuild", "-nc", "-uc", "-b"]) + cli, args = _parse_args() + + match args.operation: + case "help": + cli.print_help() + cli.exit(0) + case "version": + print(f"{cli.prog} {VERSION}") + cli.exit(0) + case "build": + build_driver_build( + build_driver=args.driver, source_dir=args.source_dir, output_dir=args.output_dir, dry_run=args.dry_run + ) + case "debuild": + run_cmd(["debuild", "-nc", "-uc", "-b"])