From 8c7c719b1c2e8c809bc1c8132c827b9c4b70cc6c Mon Sep 17 00:00:00 2001 From: Claudio Matsuoka Date: Wed, 26 Nov 2025 11:44:42 -0300 Subject: [PATCH 1/6] feat: create debs for all declared packages Iterate through all packages declared in the project file and create a deb file for each file, adding proper control metadata to each one. Signed-off-by: Claudio Matsuoka --- debcraft/application.py | 7 + debcraft/control.py | 54 +++++++ debcraft/errors.py | 38 +++++ debcraft/models/__init__.py | 4 +- debcraft/models/control.py | 50 ++++++ debcraft/models/package.py | 19 ++- debcraft/models/project.py | 28 ++++ debcraft/services/__init__.py | 3 + debcraft/services/lifecycle.py | 72 +++++++++ debcraft/services/package.py | 148 ++++++++++++------ debcraft/services/project.py | 22 +++ pyproject.toml | 1 + schema/debcraft.json | 31 +++- .../integration/debcraft/test_application.py | 23 ++- .../invalid-name/debcraft.yaml | 3 + .../minimal-adopt-info/debcraft.yaml | 3 + .../valid-projects/minimal/debcraft.yaml | 3 + .../reference-libpng/debcraft.yaml | 75 ++++----- tests/unit/conftest.py | 39 +++++ tests/unit/services/test_package.py | 13 +- 20 files changed, 528 insertions(+), 108 deletions(-) create mode 100644 debcraft/control.py create mode 100644 debcraft/errors.py create mode 100644 debcraft/models/control.py create mode 100644 debcraft/services/lifecycle.py diff --git a/debcraft/application.py b/debcraft/application.py index a509efc7..75c15546 100644 --- a/debcraft/application.py +++ b/debcraft/application.py @@ -17,6 +17,8 @@ """Main Debcraft Application.""" import craft_application +import craft_parts +from typing_extensions import override from debcraft import models @@ -29,3 +31,8 @@ class Application(craft_application.Application): """Debcraft application definition.""" + + @override + def _enable_craft_parts_features(self) -> None: + """Enable partitions for packages.""" + craft_parts.Features(enable_partitions=True) diff --git a/debcraft/control.py b/debcraft/control.py new file mode 100644 index 00000000..9a761bb8 --- /dev/null +++ b/debcraft/control.py @@ -0,0 +1,54 @@ +# This file is part of debcraft. +# +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . + +"""Debian control file encoder.""" + +from typing import TextIO + +from debcraft import models + + +class Encoder: + """Encoder for Debian control file format.""" + + def __init__(self, f: TextIO) -> None: + self._file = f + + def encode(self, model: models.DebianControl) -> None: + """Encode the model.""" + for name, field in model.__class__.model_fields.items(): + value = getattr(model, name) + if value is None: + continue + + key = field.alias or name + + match value: + case None: + continue + case str() if "\n" in value: + lines = value.splitlines() + self._file.write(f"{key}: {lines[0]}\n") + for line in lines[1:]: + if line.strip() == "": + self._file.write(" .\n") + else: + self._file.write(f" {line}\n") + case list(): + line = ", ".join(map(str, value)) # pyright: ignore[reportUnknownVariableType,reportUnknownArgumentType] + self._file.write(f"{key}: {line}\n") + case _: + self._file.write(f"{key}: {value}\n") diff --git a/debcraft/errors.py b/debcraft/errors.py new file mode 100644 index 00000000..c4a07202 --- /dev/null +++ b/debcraft/errors.py @@ -0,0 +1,38 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Debcraft error definitions.""" + +from craft_cli import CraftError + + +class DebcraftError(CraftError): + """Failure in a Debcraft operation.""" + + +class FeatureNotImplemented(DebcraftError): + """Attempt to use an unimplemented feature.""" + + def __init__(self, msg: str) -> None: + super().__init__(f"Command or feature not implemented: {msg}") + + +class PartsLifecycleError(DebcraftError): + """Error during parts processing.""" + + +class ProjectValidationError(DebcraftError): + """Error validating debcraft.yaml.""" diff --git a/debcraft/models/__init__.py b/debcraft/models/__init__.py index a194a5d8..a70faeba 100644 --- a/debcraft/models/__init__.py +++ b/debcraft/models/__init__.py @@ -18,6 +18,8 @@ from debcraft.models.metadata import Metadata from debcraft.models.project import Project +from debcraft.models.package import Package +from debcraft.models.control import DebianControl -__all__ = ["Project", "Metadata"] +__all__ = ["Project", "Package", "DebianControl", "Metadata"] diff --git a/debcraft/models/control.py b/debcraft/models/control.py new file mode 100644 index 00000000..5d4bcc02 --- /dev/null +++ b/debcraft/models/control.py @@ -0,0 +1,50 @@ +# This file is part of debcraft. +# +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . + +"""Debian control file model for Debcraft.""" + +from craft_application import models +from pydantic import ConfigDict + + +def _field_alias(field_name: str) -> str: + parts = field_name.replace("_", "-").split("-") + capitalized_parts = [p.capitalize() for p in parts] + return "-".join(capitalized_parts) + + +class DebianControl(models.CraftBaseModel): + """Debian control file definition.""" + + model_config = ConfigDict(alias_generator=_field_alias, populate_by_name=True) + + package: str + source: str + version: str + architecture: str | list[str] + maintainer: str + installed_size: int + depends: list[str] | None = None + recommends: list[str] | None = None + conflicts: list[str] | None = None + breaks: list[str] | None = None + replaces: list[str] | None = None + provides: list[str] | None = None + section: str + priority: str + description: str + original_maintainer: str | None = None + uploaders: list[str] | None = None diff --git a/debcraft/models/package.py b/debcraft/models/package.py index 12d2f1df..dd02d9d4 100644 --- a/debcraft/models/package.py +++ b/debcraft/models/package.py @@ -15,7 +15,7 @@ # with this program. If not, see . """Model for defining deb binary packages.""" -from typing import Literal +from typing import Literal, cast import pydantic from craft_application import models @@ -31,8 +31,11 @@ class Package(models.CraftBaseModel): See: https://www.debian.org/doc/debian-policy/ch-controlfields.html """ - architectures: Literal["any", "all"] | list[DebianArchitecture] - description: str | None = None # Only none for the only pkg + architectures: Literal["any", "all"] | list[DebianArchitecture] | None = None + summary: str | None = None # defaults to the project summary + description: str | None = None # defaults to the project description + + version: str | None = None # defaults to the project version # These need validating: https://github.com/canonical/debcraft/issues/42 # https://www.debian.org/doc/debian-policy/ch-relationships.html#s-binarydeps @@ -50,3 +53,13 @@ class Package(models.CraftBaseModel): Use of this key indicates something incomplete in debcraft. """ + + def get_architecture(self) -> str | list[str] | None: + """Get the formatted package architecture.""" + if self.architectures in ("all", "any"): + return cast(str, self.architectures) + + if self.architectures: + return [str(x) for x in self.architectures] + + return None diff --git a/debcraft/models/project.py b/debcraft/models/project.py index cbe6094d..485c002c 100644 --- a/debcraft/models/project.py +++ b/debcraft/models/project.py @@ -24,6 +24,7 @@ from craft_application import models from typing_extensions import Self +from debcraft import errors from debcraft.models.package import Package DEBIAN_PACKAGE_NAME_REGEX = r"^[a-z0-9][a-z0-9.+-]+$" @@ -97,3 +98,30 @@ def _validate_adopt_info_part_exists(self) -> Self: if self.adopt_info and self.adopt_info not in self.parts: raise ValueError("'adopt-info' field must refer to the name of a part.") return self + + def get_package(self, name: str) -> Package: + """Obtain the package definition for the given package name.""" + if not self.packages: + raise errors.DebcraftError("no packages defined") + + package = self.packages.get(name) + if not package: + raise errors.DebcraftError(f"package {name} is not defined") + + return package + + +class PackagesProject(models.CraftBaseModel, extra="ignore"): + """Project definition containing only package data.""" + + packages: dict[DebianPackageName, Package] | None = None + + def get_partitions(self) -> list[str] | None: + """Get a list of partitions based on the project's packages. + + :returns: A list of packages formatted as ['default', 'package/', ...] + """ + if not self.packages: + return ["default"] + + return ["default", *[f"package/{name}" for name in self.packages]] diff --git a/debcraft/services/__init__.py b/debcraft/services/__init__.py index 8dee5dc6..4a1c5256 100644 --- a/debcraft/services/__init__.py +++ b/debcraft/services/__init__.py @@ -24,6 +24,9 @@ def register_services() -> None: """Register debcraft services to the service factory.""" ServiceFactory.register("package", "Package", module="debcraft.services.package") ServiceFactory.register("project", "Project", module="debcraft.services.project") + ServiceFactory.register( + "lifecycle", "Lifecycle", module="debcraft.services.lifecycle" + ) __all__ = ["BuildPlan", "ServiceFactory"] diff --git a/debcraft/services/lifecycle.py b/debcraft/services/lifecycle.py new file mode 100644 index 00000000..43661c1d --- /dev/null +++ b/debcraft/services/lifecycle.py @@ -0,0 +1,72 @@ +# This file is part of debcraft. +# +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +# +"""Debcraft Lifecycle Service.""" + +from pathlib import Path + +from craft_application import LifecycleService +from craft_parts import ProjectInfo + +from debcraft import errors + + +class Lifecycle(LifecycleService): + """Debcraft specialization of the Lifecycle Service.""" + + def get_prime_dir(self, package: str | None = None) -> Path: + """Get the prime directory path for the default prime dir or a package. + + :param package: Name of the package to get the prime directory for. + + :returns: The default prime directory or a package's prime directory. + + :raises DebcraftError: If the package does not exist. + """ + try: + return self.prime_dirs[package] + except KeyError as err: + raise errors.DebcraftError( + f"Could not get prime directory for package {package!r} " + "because it does not exist." + ) from err + + @property + def prime_dirs(self) -> dict[str | None, Path]: + """Return a mapping of package names to prime directories. + + 'None' maps to the default prime directory. + """ + return _get_prime_dirs_from_project(self._lcm.project_info) + + +def _get_prime_dirs_from_project(project_info: ProjectInfo) -> dict[str | None, Path]: + """Get a mapping of package names to prime directories from a ProjectInfo. + + 'None' maps to the default prime directory. + + :param project_info: The ProjectInfo to get the prime directory mapping from. + """ + partition_prime_dirs = project_info.prime_dirs + package_prime_dirs: dict[str | None, Path] = {None: project_info.prime_dir} + + # strip 'component/' prefix so that the package name is the key + for partition, prime_dir in partition_prime_dirs.items(): + if partition and partition.startswith("package/"): + package = partition.split("/", 1)[1] + package_prime_dirs[package] = prime_dir + + return package_prime_dirs diff --git a/debcraft/services/package.py b/debcraft/services/package.py index 50ff0826..39beeb0e 100644 --- a/debcraft/services/package.py +++ b/debcraft/services/package.py @@ -23,15 +23,14 @@ import subprocess import tarfile import tempfile -import textwrap from typing import cast -import craft_application import zstandard as zstd from craft_application import services from craft_platforms import BuildInfo -from debcraft import models +from debcraft import control, errors, models +from debcraft.services.lifecycle import Lifecycle _ZSTD_COMPRESSION_LEVEL = 3 @@ -45,40 +44,22 @@ def pack(self, prime_dir: pathlib.Path, dest: pathlib.Path) -> list[pathlib.Path :param dest: Directory into which to write the package(s). :returns: A list of paths to created packages. """ - project = self._services.get("project").get() - build_plan = self._services.get("build_plan").plan()[0] + project = cast(models.Project, self._services.get("project").get()) + build_info = self._services.get("build_plan").plan()[0] + _ = prime_dir # not used + + if not project.packages: + return [] - with tempfile.TemporaryDirectory() as tmpdir: - cwd = pathlib.Path().absolute() - deb_name = ( - dest.absolute() - / f"{project.name}_{project.version}_{build_plan.platform}.deb" + debs: list[pathlib.Path] = [] + for package_name in project.packages: + prime = cast(Lifecycle, self._services.lifecycle).get_prime_dir( + package_name ) + deb = _create_package(dest, project, package_name, build_info, prime) + debs.append(deb) - try: - os.chdir(tmpdir) - _create_data_file(pathlib.Path(tmpdir), prime_dir) - _create_control_file(pathlib.Path(tmpdir), project, build_plan) - pathlib.Path("debian-binary").write_text("2.0\n") - - # Order of files added to the deb file is important. The - # debian-binary file must come first, followed by the control - # tarball and then the data tarball. - subprocess.run( - [ - "ar", - "rcs", - deb_name, - "debian-binary", - "control.tar.zstd", - "data.tar.zstd", - ], - check=True, - ) - finally: - os.chdir(cwd) - - return [deb_name] + return debs @property def metadata(self) -> models.Metadata: @@ -93,6 +74,49 @@ def metadata(self) -> models.Metadata: ) +def _create_package( + dest: pathlib.Path, + project: models.Project, + package_name: str, + build_info: BuildInfo, + prime_dir: pathlib.Path, +) -> pathlib.Path: + package = project.get_package(package_name) + version = package.version or project.version + cwd = pathlib.Path().absolute() + deb_name = dest.absolute() / f"{package_name}_{version}_{build_info.build_for}.deb" + + installed_size = _get_dir_size(prime_dir) + + with tempfile.TemporaryDirectory() as tmpdir: + try: + os.chdir(tmpdir) + _create_data_file(pathlib.Path(tmpdir), prime_dir) + _create_control_file( + pathlib.Path(tmpdir), project, package_name, build_info, installed_size + ) + pathlib.Path("debian-binary").write_text("2.0\n") + + # Order of files added to the deb file is important. The + # debian-binary file must come first, followed by the control + # tarball and then the data tarball. + subprocess.run( + [ + "ar", + "rcs", + deb_name, + "debian-binary", + "control.tar.zstd", + "data.tar.zstd", + ], + check=True, + ) + finally: + os.chdir(cwd) + + return deb_name + + def _create_data_file(path: pathlib.Path, prime_dir: pathlib.Path) -> None: """Create the data.tar.zstd file containing the prime contents. @@ -109,7 +133,11 @@ def _create_data_file(path: pathlib.Path, prime_dir: pathlib.Path) -> None: def _create_control_file( - path: pathlib.Path, project: craft_application.models.Project, build_plan: BuildInfo + path: pathlib.Path, + project: models.Project, + package_name: str, + build_info: BuildInfo, + installed_size: int, ) -> None: """Create the control.tar.zstd file containing package metadata. @@ -117,18 +145,46 @@ def _create_control_file( :param project: The project model. :param build_plan: Platform information. """ + package = project.get_package(package_name) control_path = path / "control.tar.zstd" + version = package.version or project.version + if not version: + raise errors.DebcraftError(f"package {package_name} version was not set") + + section = package.section or project.section + if not section: + raise errors.DebcraftError(f"package {package_name} section was not set") + + summary = package.summary or project.summary + if not summary: + raise errors.DebcraftError(f"package {package_name} summary was not set") + + description = package.description or project.description + if not description: + raise errors.DebcraftError(f"package {package_name} description was not set") + # Change to use package data from the project model - control_data = textwrap.dedent( - f"""\ - Package: {project.name} - Version: {project.version} - Architecture: {build_plan.platform} - """ + ctl_data = models.DebianControl( + package=package_name, + source=project.name, + version=version, + architecture=package.get_architecture() or build_info.build_for, + maintainer=project.maintainer, + section=section, + installed_size=int(installed_size / 1024), + depends=package.depends, + priority=project.priority.value or "optional", + description=summary + "\n" + description, + original_maintainer=project.original_maintainer, + uploaders=project.uploaders, ) - control = pathlib.Path("control") - control.write_text(control_data) + + ctlfile = pathlib.Path("control") + + with ctlfile.open("w") as f: + encoder = control.Encoder(f) + encoder.encode(ctl_data) with control_path.open("wb") as control_zstd: zcomp = zstd.ZstdCompressor(level=_ZSTD_COMPRESSION_LEVEL) @@ -136,4 +192,8 @@ def _create_control_file( with tarfile.open(fileobj=comp, mode="w") as tar: tar.add("control") - control.unlink() + ctlfile.unlink() + + +def _get_dir_size(path: pathlib.Path) -> int: + return sum(f.stat().st_size for f in pathlib.Path(path).rglob("*") if f.is_file()) diff --git a/debcraft/services/project.py b/debcraft/services/project.py index 64e500f4..429b8d18 100644 --- a/debcraft/services/project.py +++ b/debcraft/services/project.py @@ -16,14 +16,36 @@ """Project service for debcraft.""" +import pathlib +from typing import cast + import craft_platforms from craft_application import services from typing_extensions import override +from debcraft.models.project import PackagesProject + class Project(services.ProjectService): """The service for rendering Debcraft projects.""" + __project_file_path: pathlib.Path | None = None + + @override + def get_partitions_for( + self, + *, + platform: str, + build_for: str, + build_on: craft_platforms.DebianArchitecture, + ) -> list[str] | None: + project = self._preprocess( + build_for=build_for, build_on=cast(str, build_on), platform=platform + ) + + packages = PackagesProject.unmarshal(project) + return packages.get_partitions() + @override def _app_render_legacy_platforms(self) -> dict[str, craft_platforms.PlatformDict]: """Provide the default platforms if no platforms are declared. diff --git a/pyproject.toml b/pyproject.toml index 656af1f7..245941fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -305,6 +305,7 @@ ignore = [ "A003", # Class attribute shadowing built-in (reason: Class attributes don't often get bare references) "SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements # (reason: this creates long lines that get wrapped and reduces readability) + "N818", # Exception name must have Error suffix (reason: doesn't allow names like FeatureNotImplemented) # Ignored due to conflicts with ruff's formatter: # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules diff --git a/schema/debcraft.json b/schema/debcraft.json index fd006ea4..e731fa62 100644 --- a/schema/debcraft.json +++ b/schema/debcraft.json @@ -32,10 +32,26 @@ "$ref": "#/$defs/DebianArchitecture" }, "type": "array" + }, + { + "type": "null" } ], + "default": null, "title": "Architectures" }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + }, "description": { "anyOf": [ { @@ -48,6 +64,18 @@ "default": null, "title": "Description" }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + }, "depends": { "anyOf": [ { @@ -158,9 +186,6 @@ "type": "object" } }, - "required": [ - "architectures" - ], "title": "Package", "type": "object" }, diff --git a/tests/integration/debcraft/test_application.py b/tests/integration/debcraft/test_application.py index a68e3c81..836e4abb 100644 --- a/tests/integration/debcraft/test_application.py +++ b/tests/integration/debcraft/test_application.py @@ -21,6 +21,7 @@ from typing import cast import craft_application +import craft_parts import debcraft import distro import pytest @@ -58,21 +59,34 @@ def test_debcraft_pack_clean(monkeypatch, tmp_path, host_architecture: str): name: test-deb base: {HOST_DISTRO.id()}@{HOST_DISTRO.version()} version: "1.0" + summary: A test deb + description: Just a test deb maintainer: Mike Maintainer + section: libs + platforms: - {host_architecture}: + {host_architecture}: + + packages: + package-1: + version: "1.23" parts: nil: plugin: nil - """) ) monkeypatch.chdir(tmp_path) + services.ServiceFactory.register( + "project", "Project", module="debcraft.services.project" + ) services.ServiceFactory.register( "package", "Package", module="debcraft.services.package" ) + services.ServiceFactory.register( + "lifecycle", "Lifecycle", module="debcraft.services.lifecycle" + ) app_services = craft_application.ServiceFactory(app=debcraft.METADATA) app = debcraft.Application(debcraft.METADATA, app_services) @@ -95,9 +109,7 @@ def test_debcraft_pack_clean(monkeypatch, tmp_path, host_architecture: str): project=project, arch=host_architecture, ) - packed_asset = ( - tmp_path / f"{metadata.name}_{metadata.version}_{host_architecture}.deb" - ) + packed_asset = tmp_path / f"package-1_1.23_{host_architecture}.deb" assert packed_asset.exists() result = subprocess.run( @@ -106,6 +118,7 @@ def test_debcraft_pack_clean(monkeypatch, tmp_path, host_architecture: str): members = result.stdout.strip().splitlines() assert members == ["debian-binary", "control.tar.zstd", "data.tar.zstd"] + craft_parts.Features.reset() monkeypatch.setattr("sys.argv", ["debcraft", "clean", "--destructive-mode"]) result = app.run() diff --git a/tests/integration/project/invalid-projects/invalid-name/debcraft.yaml b/tests/integration/project/invalid-projects/invalid-name/debcraft.yaml index 6e89987b..373d20b0 100644 --- a/tests/integration/project/invalid-projects/invalid-name/debcraft.yaml +++ b/tests/integration/project/invalid-projects/invalid-name/debcraft.yaml @@ -2,3 +2,6 @@ name: -a- summary: A debcraft project with an invalid name. maintainer: Ubuntu Developers version: 0 + +platforms: + amd64: diff --git a/tests/integration/project/valid-projects/minimal-adopt-info/debcraft.yaml b/tests/integration/project/valid-projects/minimal-adopt-info/debcraft.yaml index 4e8027bf..d15e814d 100644 --- a/tests/integration/project/valid-projects/minimal-adopt-info/debcraft.yaml +++ b/tests/integration/project/valid-projects/minimal-adopt-info/debcraft.yaml @@ -6,6 +6,9 @@ adopt-info: yolo # No longer necessary because we have adopt-info. # version: 0 +platforms: + amd64: + # This part exists so we have a valid adopt-info. parts: yolo: diff --git a/tests/integration/project/valid-projects/minimal/debcraft.yaml b/tests/integration/project/valid-projects/minimal/debcraft.yaml index 0bac322d..c29f6b20 100644 --- a/tests/integration/project/valid-projects/minimal/debcraft.yaml +++ b/tests/integration/project/valid-projects/minimal/debcraft.yaml @@ -1,3 +1,6 @@ name: debcraft-minimal maintainer: Ubuntu Developers version: 0 + +platforms: + amd64: diff --git a/tests/integration/project/valid-projects/reference-libpng/debcraft.yaml b/tests/integration/project/valid-projects/reference-libpng/debcraft.yaml index aefb21a3..447bfa68 100644 --- a/tests/integration/project/valid-projects/reference-libpng/debcraft.yaml +++ b/tests/integration/project/valid-projects/reference-libpng/debcraft.yaml @@ -1,82 +1,69 @@ -# This is a reference file expanded from discussions on the week of 2025-11-03. -# It is the closest we have to a formal specification for now. -name: libpng2.6 # source name +name: libpng1-6 version: 1.6.43-5build1 summary: PNG library - runtime (version 1.6) -description: | # add start of line dots +description: | libpng is a library implementing an interface for reading and writing PNG (Portable Network Graphics) format files. This package contains the runtime library files needed to run software using libpng. -build-base: ubuntu@24.04 -# licenses from debcraft/copyright -# fall back to debian/copyright - -priority: optional # optional entry +base: ubuntu@24.04 maintainer: Ubuntu Developers -original-maintainer: Maintainers of libpng1.6 packages # optional entry -#uploaders: ... # optional entry +original-maintainer: Maintainers of libpng1.6 packages section: libs -# packages inherit summary, description, section unless overridden +# to be removed, see https://github.com/canonical/debcraft/issues/52 +platforms: + amd64: + packages: libpng16-16t64: - architectures: [amd64] # Literal["any", "all"] | list[DebianArchitecture] = "any" - depends: - - mydependency - # These includes should be handled implicitly. - # - ${misc:Depends} # the way today's flow works, but may be different in the future - # - ${shlibs:Depends} - - ... # more implicit ways with ways to opt out - - -libcurl* # exclude this from the list of depends - recommends: - - otherdependency - # - ${misc:Recommends} # the way today's flow works, but may be different in the future - # - ${shlibs:Recommends} - - ... # more implicit ways with ways to opt out - - -libcurl* provides: - libpng16-16 breaks: - libpng16-16 (<< 1.6.43-5build1) replaces: - libpng16-16 - conflicts: libpng-dev: section: libdevel architectures: all - # ... rest same as above + summary: PNG library - development (version 1.6) + description: | + libpng is a library implementing an interface for reading and writing + PNG (Portable Network Graphics) format files. + + This package contains the header and development files needed to build + programs and packages using libpng. + + libpng-tools: + section: libdevel + summary: PNG library - tools (version 1.6) + description: | + libpng is a library implementing an interface for reading and writing + PNG (Portable Network Graphics) format files. + + This package contains a program to interact with libpng from the + command line. parts: libpng1.6: - plugin: autotools # run debian/rules build, install docs & manpages + plugin: autotools autotools-configure-parameters: - - --prefix=/ + - --prefix=/usr source: . build-packages: - build-essential - - debhelper-compat - - dpkg-dev - zlib1g-dev - - mawk - - for amd64: # revisit this - - libexif-dev - stage: - - -dontincludeme.txt organize: usr/bin/libpng-config: (package/libpng-dev)/usr/bin/ usr/bin/libpng16-config: (package/libpng-dev)/usr/bin/ usr/include: (package/libpng-dev)/usr - # some magic necessary here to use the var usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/pkgconfig: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ - usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/libpng.a: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ - usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/libpng16.a: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ - usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/libpng.so: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ - usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/libpng16.so: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ - usr/share/doc/libpng-16: (package/libpng-dev)/usr/share/doc/ # the plugin gzips and installs doc files - usr/share/man: (package/libpng-dev)/usr/share/ # the plugin gzips and installs manpages + usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/*.a: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ + usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/*.so: (package/libpng-dev)/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ + usr/share/doc/libpng-16: (package/libpng-dev)/usr/share/doc/ + usr/share/man: (package/libpng-dev)/usr/share/ usr/bin/png-fix-itxt: (package/libpng-tools)/usr/bin/ usr/bin/pngfix: (package/libpng-tools)/usr/bin/ diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 835b8877..1fdeae66 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -18,14 +18,25 @@ import pathlib from typing import Any, cast +import craft_application +import craft_parts import debcraft import debcraft.services.package import debcraft.services.project import pytest from debcraft import models, services +from debcraft.services import lifecycle from typing_extensions import override +@pytest.fixture(scope="module", autouse=True) +def global_setup(): + craft_parts.Features.reset() + craft_parts.Features(enable_partitions=True) + yield + craft_parts.Features.reset() + + @pytest.fixture def extra_project_params(): """Configuration fixture for the Project used by the default services.""" @@ -42,9 +53,13 @@ def default_project_raw( "name": "fake-project", "version": "1.0", "base": "ubuntu@24.04", + "summary": "A package", + "description": "Really a package", "platforms": {host_architecture: None}, "parts": parts, "maintainer": "Mike Maintainer ", + "section": "libs", + "packages": {"package-1": {"version": "2.0"}}, } | extra_project_params @@ -76,16 +91,40 @@ def set(self, value: models.Project) -> None: return FakeProjectService +@pytest.fixture +def fake_lifecycle_service_class(tmp_path, host_architecture): + class FakeLifecycleService(lifecycle.Lifecycle): + def __init__( + self, + app: craft_application.AppMetadata, + services: services.ServiceFactory, + **lifecycle_kwargs: Any, + ): + super().__init__( + app, + services, + work_dir=tmp_path / "work", + cache_dir=tmp_path / "cache", + platform=None, + build_for=host_architecture, + **lifecycle_kwargs, + ) + + return FakeLifecycleService + + @pytest.fixture def default_factory( default_project, fake_project_service_class, + fake_lifecycle_service_class, project_path: pathlib.Path, ) -> services.ServiceFactory: services.ServiceFactory.register( "package", "Package", module="debcraft.services.package" ) services.ServiceFactory.register("project", fake_project_service_class) + services.ServiceFactory.register("lifecycle", fake_lifecycle_service_class) service_factory = services.ServiceFactory(app=debcraft.METADATA) service_factory.update_kwargs("project", project_dir=project_path) return service_factory diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index 440ad6b5..30edab34 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -46,15 +46,12 @@ def test_pack( default_project: models.Project, host_architecture: str, ): - (tmp_path / "prime").mkdir(exist_ok=True) - package_service_with_configured_project.pack( - prime_dir=tmp_path / "prime", dest=tmp_path - ) + prime_dir = tmp_path / "work" / "partitions" / "package" / "package-1" / "prime" + prime_dir.mkdir(exist_ok=True, parents=True) + (prime_dir / "foo.txt").touch() + package_service_with_configured_project.pack(prime_dir=prime_dir, dest=tmp_path) - deb_file = ( - tmp_path - / f"{default_project.name}_{default_project.version}_{host_architecture}.deb" - ) + deb_file = tmp_path / f"package-1_2.0_{host_architecture}.deb" assert deb_file.exists() members = _list_ar_members(deb_file) From 6eb831210910c19431bc5735a219ec8cdc8b9ed2 Mon Sep 17 00:00:00 2001 From: Claudio Matsuoka Date: Thu, 27 Nov 2025 19:54:57 -0300 Subject: [PATCH 2/6] chore: update debcraft/services/package.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Claudio Matsuoka --- debcraft/services/package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debcraft/services/package.py b/debcraft/services/package.py index 39beeb0e..bc6d3025 100644 --- a/debcraft/services/package.py +++ b/debcraft/services/package.py @@ -196,4 +196,4 @@ def _create_control_file( def _get_dir_size(path: pathlib.Path) -> int: - return sum(f.stat().st_size for f in pathlib.Path(path).rglob("*") if f.is_file()) + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) From 78785fb4da3a92cc4586fc46daed73114d883532 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:56:41 +0000 Subject: [PATCH 3/6] Initial plan From 336c19ab87f3a4a4e2a5e9c810c97780732b3560 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:00:01 +0000 Subject: [PATCH 4/6] Add unit tests for get_package and get_partitions methods Co-authored-by: cmatsuoka <317355+cmatsuoka@users.noreply.github.com> --- tests/unit/models/test_project_model.py | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/unit/models/test_project_model.py b/tests/unit/models/test_project_model.py index 94f2b04b..4fd39c10 100644 --- a/tests/unit/models/test_project_model.py +++ b/tests/unit/models/test_project_model.py @@ -78,3 +78,61 @@ def test_adopt_info_valid_part_name_error(default_project_raw): ValueError, match="'adopt-info' field must refer to the name of a part." ): project.Project.model_validate(default_project_raw) + + +# Tests for Project.get_package() +def test_get_package_success(default_project): + """Test that get_package returns the correct package.""" + package = default_project.get_package("package-1") + assert package is not None + assert package.version == "2.0" + + +@pytest.mark.parametrize( + "extra_project_params", + [ + {"packages": None}, + ], +) +def test_get_package_no_packages_defined(default_project): + """Test that get_package raises an error when no packages are defined.""" + from debcraft import errors + + with pytest.raises(errors.DebcraftError, match="no packages defined"): + default_project.get_package("any-package") + + +def test_get_package_not_found(default_project): + """Test that get_package raises an error when package is not found.""" + from debcraft import errors + + with pytest.raises( + errors.DebcraftError, match="package nonexistent-package is not defined" + ): + default_project.get_package("nonexistent-package") + + +# Tests for PackagesProject.get_partitions() +def test_get_partitions_no_packages(): + """Test that get_partitions returns ['default'] when no packages are defined.""" + packages_project = project.PackagesProject(packages=None) + result = packages_project.get_partitions() + assert result == ["default"] + + +def test_get_partitions_with_packages(): + """Test that get_partitions returns correct list when packages are defined.""" + from debcraft.models.package import Package + + packages_project = project.PackagesProject( + packages={ + "pkg-a": Package(version="1.0"), + "pkg-b": Package(version="2.0"), + } + ) + result = packages_project.get_partitions() + assert result is not None + assert "default" in result + assert "package/pkg-a" in result + assert "package/pkg-b" in result + assert len(result) == 3 From 87048dc9f30063ac10378f8bdb1e2e57da068410 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:01:30 +0000 Subject: [PATCH 5/6] Move imports to top of file per code review feedback Co-authored-by: cmatsuoka <317355+cmatsuoka@users.noreply.github.com> --- tests/unit/models/test_project_model.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/unit/models/test_project_model.py b/tests/unit/models/test_project_model.py index 4fd39c10..78270d19 100644 --- a/tests/unit/models/test_project_model.py +++ b/tests/unit/models/test_project_model.py @@ -16,7 +16,10 @@ """Unit tests for the debcraft project model.""" import pytest + +from debcraft import errors from debcraft.models import project +from debcraft.models.package import Package @pytest.mark.parametrize( @@ -96,16 +99,12 @@ def test_get_package_success(default_project): ) def test_get_package_no_packages_defined(default_project): """Test that get_package raises an error when no packages are defined.""" - from debcraft import errors - with pytest.raises(errors.DebcraftError, match="no packages defined"): default_project.get_package("any-package") def test_get_package_not_found(default_project): """Test that get_package raises an error when package is not found.""" - from debcraft import errors - with pytest.raises( errors.DebcraftError, match="package nonexistent-package is not defined" ): @@ -122,8 +121,6 @@ def test_get_partitions_no_packages(): def test_get_partitions_with_packages(): """Test that get_partitions returns correct list when packages are defined.""" - from debcraft.models.package import Package - packages_project = project.PackagesProject( packages={ "pkg-a": Package(version="1.0"), From 265e1afbe333dc9d445b8893c864390a0e360f06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:08:15 +0000 Subject: [PATCH 6/6] Remove redundant assertions per code review feedback Co-authored-by: lengau <4305943+lengau@users.noreply.github.com> --- tests/unit/models/test_project_model.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/unit/models/test_project_model.py b/tests/unit/models/test_project_model.py index 78270d19..5e98276d 100644 --- a/tests/unit/models/test_project_model.py +++ b/tests/unit/models/test_project_model.py @@ -87,7 +87,6 @@ def test_adopt_info_valid_part_name_error(default_project_raw): def test_get_package_success(default_project): """Test that get_package returns the correct package.""" package = default_project.get_package("package-1") - assert package is not None assert package.version == "2.0" @@ -128,8 +127,4 @@ def test_get_partitions_with_packages(): } ) result = packages_project.get_partitions() - assert result is not None - assert "default" in result - assert "package/pkg-a" in result - assert "package/pkg-b" in result - assert len(result) == 3 + assert result == ["default", "package/pkg-a", "package/pkg-b"]