diff --git a/snapcraft/application.py b/snapcraft/application.py index a456414915..f302ccac3f 100644 --- a/snapcraft/application.py +++ b/snapcraft/application.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 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 @@ -27,6 +27,7 @@ import craft_application.commands as craft_app_commands import craft_application.models import craft_cli +import pydantic from craft_application import Application, AppMetadata, util from craft_application.models import BuildInfo from craft_cli import emit @@ -36,6 +37,7 @@ from snapcraft import cli, errors, models, services from snapcraft.commands import unimplemented from snapcraft.models import Architecture +from snapcraft.models.project import validate_architectures from snapcraft.providers import SNAPCRAFT_BASE_TO_PROVIDER_BASE from snapcraft.utils import get_effective_base, get_host_architecture @@ -43,29 +45,50 @@ class SnapcraftBuildPlanner(craft_application.models.BuildPlanner): """A project model that creates build plans.""" + architectures: List[str | Architecture] = pydantic.Field( + default_factory=lambda: [get_host_architecture()] + ) + base: str | None = None + build_base: str | None = None + name: str | None = None + project_type: str | None = None + + @pydantic.validator("architectures", always=True) + def _validate_architecture_data( # pylint: disable=no-self-argument + cls, architectures: list[str | Architecture] + ) -> list[Architecture]: + """Validate architecture data. + + Most importantly, converts architecture strings into Architecture objects. + """ + return validate_architectures(architectures) + def get_build_plan(self) -> List[BuildInfo]: """Get the build plan for this project.""" build_plan: List[BuildInfo] = [] - architectures = cast(List[Architecture], getattr(self, "architectures", [])) - - for arch in architectures: + for arch in self.architectures: # build_for will be a single element list - build_for = cast(list, arch.build_for)[0] + if isinstance(arch, Architecture): + build_on = arch.build_on + build_for = cast(list[str], arch.build_for)[0] + else: + build_on = arch + build_for = arch # TODO: figure out when to filter `all` if build_for == "all": build_for = get_host_architecture() # build on will be a list of archs - for build_on in arch.build_on: + for build_on in cast(list[str], build_on): base = SNAPCRAFT_BASE_TO_PROVIDER_BASE[ str( get_effective_base( - base=getattr(self, "base", None), - build_base=getattr(self, "build_base", None), - name=getattr(self, "name", None), - project_type=getattr(self, "type", None), + base=self.base, + build_base=self.build_base, + name=self.name, + project_type=self.project_type, ) ) ] diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index 0007af7f0c..da2750d1e0 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -64,7 +64,7 @@ def _validate_command_chain(command_chains: Optional[List[str]]) -> Optional[Lis return command_chains -def _validate_architectures(architectures): +def validate_architectures(architectures): """Expand and validate architecture data. Validation includes: @@ -603,7 +603,7 @@ def _validate_epoch(cls, epoch): @classmethod def _validate_architecture_data(cls, architectures): """Validate architecture data.""" - return _validate_architectures(architectures) + return validate_architectures(architectures) @pydantic.validator("provenance") @classmethod @@ -794,7 +794,7 @@ class ArchitectureProject(models.CraftBaseModel, extra=pydantic.Extra.ignore): @classmethod def _validate_architecture_data(cls, architectures): """Validate architecture data.""" - return _validate_architectures(architectures) + return validate_architectures(architectures) @classmethod def unmarshal(cls, data: Dict[str, Any]) -> "ArchitectureProject": diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index fee768eedd..6efa189ba9 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -25,8 +25,8 @@ def test_pack(package_service, default_factory, mocker): mock_pack_snap = mocker.patch.object(pack, "pack_snap") - mock_linters_run = mocker.patch.object(linters, "run_linters") - mock_linters_report = mocker.patch.object(linters, "report") + mocker.patch.object(linters, "run_linters") + mocker.patch.object(linters, "report") package_service.pack(prime_dir=Path("prime"), dest=Path()) diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py new file mode 100644 index 0000000000..69fde6adc0 --- /dev/null +++ b/tests/unit/test_application.py @@ -0,0 +1,145 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 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 . +"""Unit tests for application classes.""" +from typing import cast + +import pytest +from craft_providers import bases + +from snapcraft import application +from snapcraft.models.project import Architecture + + +@pytest.fixture( + params=[ + ["amd64"], + ["riscv64"], + ["amd64", "riscv64", "s390x"], + [Architecture(build_on="amd64", build_for="riscv64")], + ] +) +def architectures(request): + return request.param + + +@pytest.mark.parametrize( + ("base", "expected_base"), + [ + ("core20", bases.BaseName("ubuntu", "20.04")), + ("core22", bases.BaseName("ubuntu", "22.04")), + ("core24", bases.BaseName("ubuntu", "24.04")), + ], +) +def test_build_planner_success_default_architecture(base, expected_base): + data = { + "base": base, + } + + planner = application.SnapcraftBuildPlanner.parse_obj(data) + + actual = planner.get_build_plan() + + archs = cast(list[Architecture], planner.architectures) + for build_info in actual: + assert build_info.base == expected_base + assert [build_info.build_for] in [a.build_for for a in archs] + assert [build_info.build_on] in [a.build_on for a in archs] + assert build_info.platform == f"{expected_base.name}@{expected_base.version}" + + +@pytest.mark.parametrize( + ("base", "expected_base"), + [ + ("core20", bases.BaseName("ubuntu", "20.04")), + ("core22", bases.BaseName("ubuntu", "22.04")), + ("core24", bases.BaseName("ubuntu", "24.04")), + ], +) +def test_build_planner_success_base_only(architectures, base, expected_base): + data = { + "architectures": architectures, + "base": base, + } + + planner = application.SnapcraftBuildPlanner.parse_obj(data) + + actual = planner.get_build_plan() + + archs = cast(list[Architecture], planner.architectures) + for build_info in actual: + assert build_info.base == expected_base + assert [build_info.build_for] in [a.build_for for a in archs] + assert [build_info.build_on] in [a.build_on for a in archs] + assert build_info.platform == f"{expected_base.name}@{expected_base.version}" + + +@pytest.mark.parametrize("base", ["core20", "core22", "core24"]) +@pytest.mark.parametrize( + ("build_base", "expected_base"), + [ + ("core20", bases.BaseName("ubuntu", "20.04")), + ("core22", bases.BaseName("ubuntu", "22.04")), + ("core24", bases.BaseName("ubuntu", "24.04")), + ], +) +def test_build_planner_success_build_base( + architectures, base, build_base, expected_base +): + data = { + "architectures": architectures, + "base": base, + "build-base": build_base, + } + + planner = application.SnapcraftBuildPlanner.parse_obj(data) + + actual = planner.get_build_plan() + + archs = cast(list[Architecture], planner.architectures) + for build_info in actual: + assert build_info.base == expected_base + assert [build_info.build_for] in [a.build_for for a in archs] + assert [build_info.build_on] in [a.build_on for a in archs] + assert build_info.platform == f"{expected_base.name}@{expected_base.version}" + + +@pytest.mark.parametrize("base", ["core20", "core22", "core24"]) +@pytest.mark.parametrize( + ("build_base", "expected_base"), + [ + ("core20", bases.BaseName("ubuntu", "20.04")), + ("core22", bases.BaseName("ubuntu", "22.04")), + ("core24", bases.BaseName("ubuntu", "24.04")), + ], +) +def test_build_planner_success_architecture_all(base, build_base, expected_base): + data = { + "architectures": [{"build-on": ["amd64"], "build-for": "all"}], + "base": base, + "build-base": build_base, + } + + planner = application.SnapcraftBuildPlanner.parse_obj(data) + + actual = planner.get_build_plan() + + architectures = cast(list[Architecture], planner.architectures) + for build_info in actual: + assert build_info.base == expected_base + assert [build_info.build_on] in [a.build_on for a in architectures] + assert build_info.platform == f"{expected_base.name}@{expected_base.version}" + + assert "all" not in [a.build_on for a in architectures]