From 802bb7a8cc9bb41f9d04be801fcad23aa6723269 Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Wed, 3 Jan 2024 13:15:34 -0600 Subject: [PATCH 1/4] test(lifecycle): test components on core22 Signed-off-by: Callahan Kovacs --- tests/unit/parts/test_parts.py | 6 +++--- tests/unit/test_projects.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/parts/test_parts.py b/tests/unit/parts/test_parts.py index e47aaf89dd..3234fa6bd3 100644 --- a/tests/unit/parts/test_parts.py +++ b/tests/unit/parts/test_parts.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-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 @@ -81,10 +81,10 @@ def test_parts_lifecycle_run(mocker, parts_data, step_name, new_dir, emitter): @pytest.mark.usefixtures("enable_partitions_feature") -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.parametrize("step_name", ["pull", "build", "stage", "prime"]) def test_parts_lifecycle_run_with_components( - mocker, base, parts_data, step_name, new_dir, emitter + mocker, base, parts_data, step_name, new_dir ): """Verify usage of the partitions feature.""" lcm_spy = mocker.spy(craft_parts, "LifecycleManager") diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index d08a13c439..f38819a742 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-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 @@ -1989,13 +1989,13 @@ def test_get_partitions(self, project, project_yaml_data, stub_component_data): components = {"foo": stub_component_data, "bar-baz": stub_component_data} test_project = project.unmarshal(project_yaml_data(components=components)) - component_names = test_project.get_partitions() + partitions = test_project.get_partitions() - assert component_names == ["default", "component/foo", "component/bar-baz"] + assert partitions == ["default", "component/foo", "component/bar-baz"] def test_get_partitions_none(self, project, project_yaml_data): test_project = project.unmarshal(project_yaml_data()) - component_names = test_project.get_partitions() + partitions = test_project.get_partitions() - assert component_names is None + assert partitions is None From de1390e5cd3d4b207c730306daf7267d2b7ca9b1 Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Wed, 3 Jan 2024 13:20:08 -0600 Subject: [PATCH 2/4] feat(project): add `get_component_names()` function Signed-off-by: Callahan Kovacs --- snapcraft/projects.py | 14 ++++++++++++++ tests/unit/test_projects.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/snapcraft/projects.py b/snapcraft/projects.py index 258d46b79d..186d64cc40 100644 --- a/snapcraft/projects.py +++ b/snapcraft/projects.py @@ -822,6 +822,13 @@ def get_build_for_arch_triplet(self) -> Optional[str]: return None + def get_component_names(self) -> Optional[List[str]]: + """Get a list of component names. + + :returns: A list of component names or None if no components are defined. + """ + return list(self.components.keys()) if self.components else None + def get_partitions(self) -> Optional[List[str]]: """Get a list of partitions based on the project's components. @@ -937,6 +944,13 @@ def unmarshal(cls, data: Dict[str, Any]) -> "ComponentProject": return components + def get_component_names(self) -> Optional[List[str]]: + """Get a list of component names. + + :returns: A list of component names or None if no components are defined. + """ + return list(self.components.keys()) if self.components else None + def get_partitions(self) -> Optional[List[str]]: """Get a list of partitions based on the project's components. diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index f38819a742..1f73e4685f 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -1985,6 +1985,21 @@ def test_project_version_invalid( with pytest.raises(errors.ProjectValidationError, match=error): project.unmarshal(project_yaml_data(components=component)) + def test_get_component_names(self, project, project_yaml_data, stub_component_data): + components = {"foo": stub_component_data, "bar-baz": stub_component_data} + test_project = project.unmarshal(project_yaml_data(components=components)) + + component_names = test_project.get_component_names() + + assert component_names == ["foo", "bar-baz"] + + def test_get_component_names_none(self, project, project_yaml_data): + test_project = project.unmarshal(project_yaml_data()) + + component_names = test_project.get_component_names() + + assert component_names is None + def test_get_partitions(self, project, project_yaml_data, stub_component_data): components = {"foo": stub_component_data, "bar-baz": stub_component_data} test_project = project.unmarshal(project_yaml_data(components=components)) From 58753172db6da0ceb3c494c729f91c2be1847a98 Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Wed, 3 Jan 2024 13:21:23 -0600 Subject: [PATCH 3/4] feat(meta): generate metadata for components Signed-off-by: Callahan Kovacs --- snapcraft/meta/component_yaml.py | 108 ++++++++++++++++ snapcraft/parts/lifecycle.py | 14 ++- snapcraft/parts/parts.py | 21 +++- .../snap/expected-man-pages-component.yaml | 5 + .../spread/core22/components/simple/task.yaml | 5 + tests/unit/meta/test_component_yaml.py | 118 ++++++++++++++++++ tests/unit/parts/test_lifecycle.py | 47 ++++++- tests/unit/parts/test_parts.py | 74 +++++++++++ 8 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 snapcraft/meta/component_yaml.py create mode 100644 tests/spread/core22/components/simple/snap/expected-man-pages-component.yaml create mode 100644 tests/unit/meta/test_component_yaml.py diff --git a/snapcraft/meta/component_yaml.py b/snapcraft/meta/component_yaml.py new file mode 100644 index 0000000000..f3c5fe136e --- /dev/null +++ b/snapcraft/meta/component_yaml.py @@ -0,0 +1,108 @@ +# -*- 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 . + +"""Model and utilities for component.yaml metadata.""" + +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml +from pydantic import Extra +from pydantic_yaml import YamlModel + +from snapcraft.errors import SnapcraftError +from snapcraft.projects import Project + + +class ComponentMetadata(YamlModel): + """The component.yaml model.""" + + component: str + type: str + version: Optional[str] + summary: str + description: str + + class Config: # pylint: disable=too-few-public-methods + """Pydantic model configuration.""" + + allow_population_by_field_name = True + alias_generator = lambda s: s.replace("_", "-") # noqa: E731 + extra = Extra.forbid + + @classmethod + def unmarshal(cls, data: Dict[str, Any]) -> "ComponentMetadata": + """Create and populate a new ``ComponentMetadata`` object from dictionary data. + + The unmarshal method validates entries in the input dictionary, populating + the corresponding fields in the data object. + + :param data: The dictionary data to unmarshal. + + :return: The newly created object. + + :raise TypeError: If data is not a dictionary. + """ + if not isinstance(data, dict): + raise TypeError("data is not a dictionary") + + return cls(**data) + + +def write(project: Project, component_name: str, component_prime_dir: Path) -> None: + """Create a component.yaml file. + + :param project: The snapcraft project. + :param component_name: Name of the component. + :param component_prime_dir: The directory containing the component's primed contents. + """ + meta_dir = component_prime_dir / "meta" + meta_dir.mkdir(parents=True, exist_ok=True) + + if not project.components: + raise SnapcraftError("Project does not contain any components.") + + component = project.components.get(component_name) + + if not component: + raise SnapcraftError("Component does not exist.") + + component_metadata = ComponentMetadata( + component=f"{project.name}+{component_name}", + type=component.type, + version=component.version, + summary=component.summary, + description=component.description, + ) + + yaml.add_representer(str, _repr_str, Dumper=yaml.SafeDumper) + yaml_data = component_metadata.yaml( + by_alias=True, + exclude_none=True, + allow_unicode=True, + sort_keys=False, + width=1000, + ) + + component_yaml = meta_dir / "component.yaml" + component_yaml.write_text(yaml_data) + + +def _repr_str(dumper, data): + """Multi-line string representer for the YAML dumper.""" + if "\n" in data: + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) diff --git a/snapcraft/parts/lifecycle.py b/snapcraft/parts/lifecycle.py index 5a2fe3a544..682f3809b7 100644 --- a/snapcraft/parts/lifecycle.py +++ b/snapcraft/parts/lifecycle.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-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 @@ -33,7 +33,7 @@ from snapcraft.elf import Patcher, SonameCache, elf_utils from snapcraft.elf import errors as elf_errors from snapcraft.linters import LinterStatus -from snapcraft.meta import manifest, snap_yaml +from snapcraft.meta import component_yaml, manifest, snap_yaml from snapcraft.projects import ( Architecture, ArchitectureProject, @@ -318,6 +318,16 @@ def _generate_metadata( snap_yaml.write(project, lifecycle.prime_dir, arch=project.get_build_for()) emit.progress("Generated snap metadata", permanent=True) + if components := project.get_component_names(): + emit.progress("Generating component metadata...") + for component in components: + component_yaml.write( + project=project, + component_name=component, + component_prime_dir=lifecycle.get_prime_dir_for_component(component), + ) + emit.progress("Generated component metadata", permanent=True) + if parsed_args.enable_manifest: _generate_manifest( project, diff --git a/snapcraft/parts/parts.py b/snapcraft/parts/parts.py index 6224869ca8..4bf51991e3 100644 --- a/snapcraft/parts/parts.py +++ b/snapcraft/parts/parts.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-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 @@ -118,6 +118,25 @@ def __init__( # noqa PLR0913 except craft_parts.PartsError as err: raise errors.PartsLifecycleError(str(err)) from err + def get_prime_dir_for_component(self, component: str) -> pathlib.Path: + """Get the prime directory path for a component. + + :param component: Name of the component to get the prime directory for. + + :returns: The component's prime directory. + + :raises SnapcraftError: If the component does not exist. + """ + try: + return self._lcm.project_info.get_prime_dir( + partition=f"component/{component}" + ) + except ValueError as err: + raise errors.SnapcraftError( + f"Could not get prime directory for component {component!r} " + "because it does not exist." + ) from err + @property def prime_dir(self) -> pathlib.Path: """Return the parts prime directory path.""" diff --git a/tests/spread/core22/components/simple/snap/expected-man-pages-component.yaml b/tests/spread/core22/components/simple/snap/expected-man-pages-component.yaml new file mode 100644 index 0000000000..d5e9eda828 --- /dev/null +++ b/tests/spread/core22/components/simple/snap/expected-man-pages-component.yaml @@ -0,0 +1,5 @@ +component: snap-with-components+man-pages +type: test +version: '1.0' +summary: Hello World +description: Hello World diff --git a/tests/spread/core22/components/simple/task.yaml b/tests/spread/core22/components/simple/task.yaml index 9e13482371..18f5fb8a6e 100644 --- a/tests/spread/core22/components/simple/task.yaml +++ b/tests/spread/core22/components/simple/task.yaml @@ -21,3 +21,8 @@ execute: | exit 1 fi + # assert contents of component metadata + if ! diff prime/component/man-pages/meta/component.yaml expected-man-pages-component.yaml; then + echo "Metadata for the man-pages component is incorrect." + exit 1 + fi diff --git a/tests/unit/meta/test_component_yaml.py b/tests/unit/meta/test_component_yaml.py new file mode 100644 index 0000000000..f9c873bf4c --- /dev/null +++ b/tests/unit/meta/test_component_yaml.py @@ -0,0 +1,118 @@ +# -*- 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 . + +import textwrap +from pathlib import Path + +import pytest + +from snapcraft.errors import SnapcraftError +from snapcraft.meta import component_yaml +from snapcraft.meta.component_yaml import ComponentMetadata +from snapcraft.projects import Project + + +@pytest.fixture +def stub_project_data(): + return { + "name": "mytest", + "version": "1.29.3", + "base": "core22", + "summary": "Single-line elevator pitch for your amazing snap", + "description": "test-description", + "confinement": "strict", + "parts": { + "part1": { + "plugin": "nil", + }, + }, + "apps": { + "app1": { + "command": "bin/mytest", + }, + }, + "components": { + "component-a": { + "type": "test", + "summary": "test summary", + "description": "test description", + "version": "1.0", + }, + }, + } + + +def test_unmarshal_component(): + """Unmarshal a dictionary containing a component.""" + component_data = { + "component": "mytest+component-a", + "type": "test", + "version": "1.0", + "summary": "test summary", + "description": "test description", + } + + component = ComponentMetadata.unmarshal(component_data) + + assert component.component == "mytest+component-a" + assert component.type == "test" + assert component.version == "1.0" + assert component.summary == "test summary" + assert component.description == "test description" + + +def test_write_component_yaml(stub_project_data, new_dir): + """Write a component.yaml file from a project.""" + project = Project.unmarshal(stub_project_data) + yaml_file = Path("meta/component.yaml") + + component_yaml.write( + project, component_name="component-a", component_prime_dir=new_dir + ) + + assert yaml_file.is_file() + assert yaml_file.read_text() == textwrap.dedent( + """\ + component: mytest+component-a + type: test + version: '1.0' + summary: test summary + description: test description + """ + ) + + +def test_write_component_no_components(stub_project_data, new_dir): + """Raise an error if no components are defined.""" + stub_project_data.pop("components") + project = Project.unmarshal(stub_project_data) + + with pytest.raises(SnapcraftError) as raised: + component_yaml.write( + project, component_name="component-a", component_prime_dir=new_dir + ) + + assert str(raised.value) == "Project does not contain any components." + + +def test_write_component_non_existent(stub_project_data, new_dir): + """Raise an error if the component does not exist.""" + project = Project.unmarshal(stub_project_data) + + with pytest.raises(SnapcraftError) as raised: + component_yaml.write(project, component_name="bad", component_prime_dir=new_dir) + + assert str(raised.value) == "Component does not exist." diff --git a/tests/unit/parts/test_lifecycle.py b/tests/unit/parts/test_lifecycle.py index 5e83ead13e..ef99d3f999 100644 --- a/tests/unit/parts/test_lifecycle.py +++ b/tests/unit/parts/test_lifecycle.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-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 @@ -2021,3 +2021,48 @@ def test_lifecycle_write_metadata( primed_stage_packages=[], ) ] + + +@pytest.mark.usefixtures("enable_partitions_feature", "project_vars") +def test_lifecycle_write_component_metadata( + snapcraft_yaml, new_dir, mocker, stub_component_data +): + """Component metadata should be written during the lifecycle.""" + yaml_data = snapcraft_yaml(base="core22", components=stub_component_data) + project = Project.unmarshal(snapcraft_yaml(**yaml_data)) + mocker.patch("snapcraft.parts.PartsLifecycle.run") + mocker.patch("snapcraft.pack.pack_snap") + mock_write = mocker.patch("snapcraft.meta.component_yaml.write") + + parsed_args = argparse.Namespace( + debug=False, + destructive_mode=True, + use_lxd=False, + enable_manifest=True, + ua_token=None, + parts=[], + manifest_image_information=None, + ) + + parts_lifecycle._run_command( + "prime", + project=project, + parse_info={}, + assets_dir=Path(), + start_time=datetime.now(), + parallel_build_count=8, + parsed_args=parsed_args, + ) + + assert mock_write.mock_calls == [ + call( + project=project, + component_name="foo", + component_prime_dir=new_dir / "prime/component/foo", + ), + call( + project=project, + component_name="bar-baz", + component_prime_dir=new_dir / "prime/component/bar-baz", + ), + ] diff --git a/tests/unit/parts/test_parts.py b/tests/unit/parts/test_parts.py index 3234fa6bd3..5912503c67 100644 --- a/tests/unit/parts/test_parts.py +++ b/tests/unit/parts/test_parts.py @@ -108,8 +108,17 @@ def test_parts_lifecycle_run_with_components( partitions=["default", "component/foo", "component/bar"], ) lifecycle.run(step_name) + assert lifecycle.prime_dir == Path(new_dir, "prime/default") assert lifecycle.prime_dir.is_dir() + assert lifecycle.get_prime_dir_for_component(component="foo") == Path( + new_dir, "prime/component/foo" + ) + assert lifecycle.get_prime_dir_for_component(component="foo").is_dir() + assert lifecycle.get_prime_dir_for_component(component="bar") == Path( + new_dir, "prime/component/bar" + ) + assert lifecycle.get_prime_dir_for_component(component="bar").is_dir() assert lcm_spy.call_args[1]["partitions"] == [ "default", "component/foo", @@ -118,6 +127,71 @@ def test_parts_lifecycle_run_with_components( assert craft_parts.Features().enable_partitions +@pytest.mark.parametrize("base", CURRENT_BASES) +def test_parts_lifecycle_get_prime_dir_no_components(base, parts_data, new_dir): + """Raise an error when getting the prime directory and no components are defined.""" + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + base=base, + project_base=base, + confinement="strict", + parallel_build_count=8, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + extra_build_snaps=None, + track_stage_packages=True, + target_arch="amd64", + partitions=None, + ) + + with pytest.raises(errors.SnapcraftError) as raised: + lifecycle.get_prime_dir_for_component("bad") + + assert str(raised.value) == ( + "Could not get prime directory for component 'bad' because it does not exist." + ) + + +@pytest.mark.usefixtures("enable_partitions_feature") +@pytest.mark.parametrize("base", CURRENT_BASES) +def test_parts_lifecycle_get_prime_dir_non_existent_component( + base, parts_data, new_dir +): + """Raise an error when getting the prime directory of a non-existent component.""" + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + base=base, + project_base=base, + confinement="strict", + parallel_build_count=8, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + extra_build_snaps=None, + track_stage_packages=True, + target_arch="amd64", + partitions=["default", "component/foo", "component/bar"], + ) + + with pytest.raises(errors.SnapcraftError) as raised: + lifecycle.get_prime_dir_for_component("bad") + + assert str(raised.value) == ( + "Could not get prime directory for component 'bad' because it does not exist." + ) + + def test_parts_lifecycle_run_bad_step(parts_data, new_dir): lifecycle = PartsLifecycle( parts_data, From 9d07028c565804c0fca705baefa304aa1a00feaa Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Thu, 4 Jan 2024 09:37:02 -0600 Subject: [PATCH 4/4] chore: feedback from PR Signed-off-by: Callahan Kovacs --- snapcraft/projects.py | 12 ++++++------ tests/unit/test_projects.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/snapcraft/projects.py b/snapcraft/projects.py index 186d64cc40..23d3173648 100644 --- a/snapcraft/projects.py +++ b/snapcraft/projects.py @@ -822,12 +822,12 @@ def get_build_for_arch_triplet(self) -> Optional[str]: return None - def get_component_names(self) -> Optional[List[str]]: + def get_component_names(self) -> List[str]: """Get a list of component names. - :returns: A list of component names or None if no components are defined. + :returns: A list of component names. """ - return list(self.components.keys()) if self.components else None + return list(self.components.keys()) if self.components else [] def get_partitions(self) -> Optional[List[str]]: """Get a list of partitions based on the project's components. @@ -944,12 +944,12 @@ def unmarshal(cls, data: Dict[str, Any]) -> "ComponentProject": return components - def get_component_names(self) -> Optional[List[str]]: + def get_component_names(self) -> List[str]: """Get a list of component names. - :returns: A list of component names or None if no components are defined. + :returns: A list of component names. """ - return list(self.components.keys()) if self.components else None + return list(self.components.keys()) if self.components else [] def get_partitions(self) -> Optional[List[str]]: """Get a list of partitions based on the project's components. diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index 1f73e4685f..2667086904 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -1998,7 +1998,7 @@ def test_get_component_names_none(self, project, project_yaml_data): component_names = test_project.get_component_names() - assert component_names is None + assert component_names == [] def test_get_partitions(self, project, project_yaml_data, stub_component_data): components = {"foo": stub_component_data, "bar-baz": stub_component_data}