diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index de6dae5..b496111 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,6 @@ } }, "postCreateCommand": "./.devcontainer/post-create.sh", - // Configure tool-specific properties. "customizations": { "vscode": { @@ -24,4 +23,4 @@ // work properly to allow the debmagic package to be globally installed in the system python environment "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/debmagic,type=bind,consistency=uncached", "workspaceFolder": "/workspaces/debmagic" -} +} \ No newline at end of file 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..c72b331 100644 --- a/debian/control +++ b/debian/control @@ -5,10 +5,16 @@ Uploaders: Jonas Jelten , Build-Depends: debhelper-compat (= 13), - python3-debian + dh-python, + python3-all, + pybuild-plugin-pyproject, + python3-setuptools, + python3-debian, + python3-pydantic 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 +23,9 @@ Package: debmagic Architecture: all Depends: python3-debian, + python3-pydantic, + ${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..894b09d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,12 @@ 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"] -dependencies = ["python-debian>=1.0"] +dependencies = ["python-debian>=1.0", "pydantic>=2,<3"] [project.scripts] debmagic = "debmagic.cli:main" @@ -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..591adf6 --- /dev/null +++ b/src/debmagic/_build_driver/build.py @@ -0,0 +1,136 @@ +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 debmagic._utils import copy_file_if_exists + +from .common import BuildConfig, BuildDriver, BuildDriverType, BuildMetadata, PackageDescription + +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 _driver_from_build_root(build_root: Path): + build_metadata_path = build_root / "build.json" + if not build_metadata_path.is_file(): + raise RuntimeError(f"{build_metadata_path} does not exist") + try: + metadata = BuildMetadata.model_validate_json(build_metadata_path.read_text()) + except: + raise RuntimeError(f"{build_metadata_path} is invalid") + + match metadata.driver: + case "docker": + return BuildDriverDocker.from_build_metadata(metadata) + case "lxd": + return BuildDriverLxd.from_build_metadata(metadata) + case "none": + return BuildDriverNone.from_build_metadata(metadata) + case _: + raise RuntimeError(f"Unknown build driver {metadata.driver}") + + +def _write_build_metadata(config: BuildConfig, driver: BuildDriver): + driver_metadata = driver.get_build_metadata() + build_metadata_path = config.build_root_dir / "build.json" + metadata = BuildMetadata( + build_root=config.build_root_dir, + source_dir=config.build_source_dir, + driver=driver.driver_type(), + driver_metadata=driver_metadata, + ) + build_metadata_path.write_text(metadata.model_dump_json()) + + +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 _get_package_build_root_and_identifier(package: PackageDescription) -> tuple[str, Path]: + package_identifier = f"{package.name}-{package.version}" + build_root = DEBMAGIC_TEMP_BUILD_PARENT_DIR / package_identifier + return package_identifier, build_root + + +def _prepare_build_env(package: PackageDescription, output_dir: Path, dry_run: bool) -> BuildConfig: + package_identifier, build_root = _get_package_build_root_and_identifier(package) + if build_root.exists(): + shutil.rmtree(build_root) + + config = BuildConfig( + package_identifier=package_identifier, + source_dir=package.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(package.source_dir / ".gitignore") + shutil.copytree(config.source_dir, config.build_source_dir, dirs_exist_ok=True, ignore=source_ignore_pattern) + + return config + + +def get_shell_in_build(package: PackageDescription): + _, build_root = _get_package_build_root_and_identifier(package) + driver = _driver_from_build_root(build_root=build_root) + driver.drop_into_shell() + + +def build( + package: PackageDescription, + build_driver: BuildDriverType, + output_dir: Path, + dry_run: bool = False, +): + config = _prepare_build_env(package=package, output_dir=output_dir, dry_run=dry_run) + + driver = _create_driver(build_driver, config) + _write_build_metadata(config, driver) + 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..3bc744c --- /dev/null +++ b/src/debmagic/_build_driver/common.py @@ -0,0 +1,99 @@ +import abc +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Self, Sequence + +from pydantic import BaseModel + +BuildDriverType = Literal["docker"] | Literal["lxd"] | Literal["none"] +SUPPORTED_BUILD_DRIVERS: list[BuildDriverType] = ["docker", "none"] + + +class BuildError(RuntimeError): + pass + + +@dataclass +class PackageDescription: + name: str + version: str + source_dir: Path + + +DriverSpecificBuildMetadata = dict[str, str] # expand as needed + + +class BuildMetadata(BaseModel): + driver: BuildDriverType + build_root: Path + source_dir: Path + driver_metadata: DriverSpecificBuildMetadata + + +@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_identifier(self) -> str: + # TODO: include distro + distro version + architecture + return self.package_identifier + + @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 + + @classmethod + @abc.abstractmethod + def from_build_metadata(cls, build_metadata: BuildMetadata) -> Self: + pass + + @abc.abstractmethod + def get_build_metadata(self) -> DriverSpecificBuildMetadata: + 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 + + @abc.abstractmethod + def driver_type(self) -> BuildDriverType: + 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..5f6d4b1 --- /dev/null +++ b/src/debmagic/_build_driver/driver_docker.py @@ -0,0 +1,132 @@ +import uuid +from pathlib import Path +from typing import Self, Sequence + +from debmagic._build_driver.common import ( + BuildConfig, + BuildDriver, + BuildDriverType, + BuildError, + BuildMetadata, + DriverSpecificBuildMetadata, +) +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, build_root: Path, dry_run: bool, container_name: str): + self._build_root = build_root + self._dry_run = dry_run + + 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._build_root): + raise BuildError("Cannot run in a path not relative to the original source directory") + rel = path_in_source.relative_to(self._build_root) + 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 = f"debmagic-{config.build_identifier}" + 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(dry_run=config.dry_run, build_root=config.build_root_dir, container_name=docker_container_name) + return instance + + @classmethod + def from_build_metadata(cls, build_metadata: BuildMetadata) -> Self: + assert build_metadata.driver == "docker" + container_name = build_metadata.driver_metadata.get("container_name") + if container_name is None or not isinstance(container_name, str): + raise RuntimeError("container_name not specified in build metadata, cannot instantiate build driver") + return cls(dry_run=False, build_root=build_metadata.build_root, container_name=container_name) + + def get_build_metadata(self) -> DriverSpecificBuildMetadata: + return {"container_name": self._container_name} + + 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._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._dry_run) + + def drop_into_shell(self): + if not self._dry_run: + run_cmd_in_foreground( + [ + "docker", + "exec", + "--interactive", + "--workdir", + self._translate_path_in_container(self._build_root), + "--tty", + self._container_name, + "/usr/bin/env", + "bash", + ] + ) + + def driver_type(self) -> BuildDriverType: + return "docker" diff --git a/src/debmagic/_build_driver/driver_lxd.py b/src/debmagic/_build_driver/driver_lxd.py new file mode 100644 index 0000000..dc47aa9 --- /dev/null +++ b/src/debmagic/_build_driver/driver_lxd.py @@ -0,0 +1,23 @@ +from pathlib import Path +from typing import Self, Sequence + +from debmagic._build_driver.common import BuildConfig, BuildDriver + + +class BuildDriverLxd(BuildDriver): + @classmethod + def create(cls, config: BuildConfig) -> Self: + raise NotImplementedError() + + @classmethod + def from_build_root(cls, build_root: Path) -> Self: + raise NotImplementedError() + + 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..04b56a6 100644 --- a/src/debmagic/_utils.py +++ b/src/debmagic/_utils.py @@ -1,9 +1,13 @@ import io +import os import re import shlex +import shutil +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 +41,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 +59,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 +74,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) @@ -114,6 +202,16 @@ def list_strip_head(data: list[T], head: list[T]) -> list[T]: return data[idx:] +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") + + if __name__ == "__main__": import doctest 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..840ce31 100644 --- a/src/debmagic/cli.py +++ b/src/debmagic/cli.py @@ -1,14 +1,60 @@ import argparse +from pathlib import Path -from debmagic._utils import run_cmd +from debmagic._build_driver.build import PackageDescription, get_shell_in_build +from debmagic._build_driver.build import build as build_driver_build +from debmagic._build_driver.common import SUPPORTED_BUILD_DRIVERS +from debmagic._version import VERSION -def parse_args(): - parser = argparse.ArgumentParser(description="Debmagic") - return parser.parse_args() +def _create_parser() -> argparse.ArgumentParser: + 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" + ) + + 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)") + + shell_cli = sp.add_parser("shell", parents=[common_cli], help="Attach a shell to a running debmagic build") + shell_cli.add_argument("-s", "--source-dir", type=Path, default=Path.cwd()) + + sp.add_parser("test", parents=[common_cli], help="Run package tests") + return cli def main(): - # args = parse_args() - # just call debuild for now - run_cmd(["debuild", "-nc", "-uc", "-b"]) + cli = _create_parser() + args = cli.parse_args() + + match args.operation: + case "help": + cli.print_help() + cli.exit(0) + case "version": + print(f"{cli.prog} {VERSION}") + cli.exit(0) + case "shell": + get_shell_in_build(package=PackageDescription(name="debmagic", version="0.1.0", source_dir=args.source_dir)) + cli.exit(0) + case "build": + build_driver_build( + package=PackageDescription(name="debmagic", version="0.1.0", source_dir=args.source_dir), + build_driver=args.driver, + output_dir=args.output_dir, + dry_run=args.dry_run, + ) + cli.exit(0) diff --git a/uv.lock b/uv.lock index b020d33..6d2b9eb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -141,6 +150,7 @@ name = "debmagic" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pydantic" }, { name = "python-debian" }, ] @@ -154,7 +164,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "python-debian", specifier = ">=1.0" }] +requires-dist = [ + { name = "pydantic", specifier = ">=2,<3" }, + { name = "python-debian", specifier = ">=1.0" }, +] [package.metadata.requires-dev] dev = [ @@ -303,6 +316,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -435,6 +534,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "virtualenv" version = "20.35.4"