Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(meta): generate metadata for components #4503

Merged
merged 4 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions snapcraft/meta/component_yaml.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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)
14 changes: 12 additions & 2 deletions snapcraft/parts/lifecycle.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion snapcraft/parts/parts.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down
14 changes: 14 additions & 0 deletions snapcraft/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,13 @@ def get_build_for_arch_triplet(self) -> Optional[str]:

return None

def get_component_names(self) -> List[str]:
"""Get a list of component names.

:returns: A list of component names.
"""
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.

Expand Down Expand Up @@ -937,6 +944,13 @@ def unmarshal(cls, data: Dict[str, Any]) -> "ComponentProject":

return components

def get_component_names(self) -> List[str]:
"""Get a list of component names.

:returns: A list of component names.
"""
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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
component: snap-with-components+man-pages
type: test
version: '1.0'
summary: Hello World
description: Hello World
5 changes: 5 additions & 0 deletions tests/spread/core22/components/simple/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 118 additions & 0 deletions tests/unit/meta/test_component_yaml.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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."
47 changes: 46 additions & 1 deletion tests/unit/parts/test_lifecycle.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
),
]
Loading
Loading