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..bc6d3025 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 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/models/test_project_model.py b/tests/unit/models/test_project_model.py
index 94f2b04b..5e98276d 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(
@@ -78,3 +81,50 @@ 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.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."""
+ 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."""
+ 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."""
+ packages_project = project.PackagesProject(
+ packages={
+ "pkg-a": Package(version="1.0"),
+ "pkg-b": Package(version="2.0"),
+ }
+ )
+ result = packages_project.get_partitions()
+ assert result == ["default", "package/pkg-a", "package/pkg-b"]
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)