From aeb9eac189a99e145540600117d5547d3b9febb7 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Thu, 22 Feb 2024 10:24:06 -0300 Subject: [PATCH 1/2] fix(project): fix core24/devel validation The regular validator's 'values' parameter contains the fields that have been validated so far (a bad footgun). --- snapcraft/models/project.py | 6 +++--- tests/unit/models/test_projects.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index da2750d1e0..de83560f4b 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -571,14 +571,14 @@ def _validate_grade_and_build_base(cls, values): raise ValueError("grade must be 'devel' when build-base is 'devel'") return values - @pydantic.validator("base", always=True) + @pydantic.root_validator() @classmethod - def _validate_base(cls, base, values): + def _validate_base(cls, values): """Not allowed to use unstable base without devel build-base.""" if values.get("base") == "core24" and values.get("build_base") != "devel": raise ValueError("build-base must be 'devel' when base is 'core24'") - return base + return values @pydantic.validator("build_base", always=True) @classmethod diff --git a/tests/unit/models/test_projects.py b/tests/unit/models/test_projects.py index ad0ee494f7..57927de16c 100644 --- a/tests/unit/models/test_projects.py +++ b/tests/unit/models/test_projects.py @@ -614,6 +614,12 @@ def test_project_build_base_devel_grade_stable_error(self, project_yaml_data): with pytest.raises(errors.ProjectValidationError, match=error): Project.unmarshal(project_yaml_data(build_base="devel", grade="stable")) + def test_project_development_base_error(self, project_yaml_data): + error = "build-base must be 'devel' when base is 'core24'" + + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(base="core24")) + def test_project_global_plugs_warning(self, project_yaml_data, emitter): data = project_yaml_data(plugs={"desktop": None, "desktop-legacy": None}) Project.unmarshal(data) From 72ddcc26aa68f1a268b470c307a999d9cb3f1416 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Thu, 22 Feb 2024 16:01:32 -0300 Subject: [PATCH 2/2] feat(app): support extensions in core24 This commit adds support for extensions in core24 runs. This takes two changes: - The extension-related commands (list- and expand-) are sort of "ported over" to craft-application's AppCommand base class. The commands for core22 and core24 now share the implementation. - SnapcraftApplication is updated to actually apply the extensions when loading the project. Since we don't have any core24-capable extensions yet, this is verified at the regular-test level with an integration-ish test that runs the application and verifies that the dummy test extension is applied. --- snapcraft/application.py | 30 +++++--- snapcraft/commands/__init__.py | 3 + snapcraft/commands/extensions.py | 29 ++++++++ tests/unit/commands/test_expand_extensions.py | 64 ++++++++++++----- tests/unit/commands/test_list_extensions.py | 4 +- tests/unit/conftest.py | 3 +- tests/unit/test_application.py | 68 +++++++++++++++++++ 7 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 snapcraft/commands/extensions.py diff --git a/snapcraft/application.py b/snapcraft/application.py index 9883f1156b..ecf1170c2e 100644 --- a/snapcraft/application.py +++ b/snapcraft/application.py @@ -34,9 +34,9 @@ from craft_providers import bases from overrides import override -import snapcraft.commands -from snapcraft import cli, errors, models, services +from snapcraft import cli, commands, errors, models, services from snapcraft.commands import unimplemented +from snapcraft.extensions import apply_extensions from snapcraft.models import Architecture from snapcraft.models.project import validate_architectures from snapcraft.providers import SNAPCRAFT_BASE_TO_PROVIDER_BASE @@ -153,6 +153,14 @@ def app_config(self) -> dict[str, Any]: config["core24"] = self._known_core24 return config + @override + def _extra_yaml_transform( + self, yaml_data: dict[str, Any], *, build_on: str, build_for: str | None + ) -> dict[str, Any]: + arch = build_on + target_arch = build_for if build_for else get_host_architecture() + return apply_extensions(yaml_data, arch=arch, target_arch=target_arch) + @override def _get_dispatcher(self) -> craft_cli.Dispatcher: """Configure this application. Should be called by the run method. @@ -235,8 +243,8 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher: return dispatcher -def main() -> int: - """Run craft-application based snapcraft with classic fallback.""" +def create_app() -> Snapcraft: + """Create a Snapcraft application with the proper commands.""" snapcraft_services = services.SnapcraftServiceFactory(app=APP_METADATA) app = Snapcraft( @@ -254,7 +262,7 @@ def main() -> int: craft_app_commands.lifecycle.StageCommand, craft_app_commands.lifecycle.PrimeCommand, craft_app_commands.lifecycle.PackCommand, - snapcraft.commands.lifecycle.SnapCommand, # Hidden (legacy compatibility) + commands.SnapCommand, # Hidden (legacy compatibility) unimplemented.RemoteBuild, unimplemented.Plugins, unimplemented.ListPlugins, @@ -264,9 +272,8 @@ def main() -> int: app.add_command_group( "Extensions", [ - unimplemented.ListExtensions, - unimplemented.Extensions, - unimplemented.ExpandExtensions, + commands.ListExtensions, + commands.ExpandExtensions, ], ) app.add_command_group( @@ -337,6 +344,13 @@ def main() -> int: ], ) + return app + + +def main() -> int: + """Run craft-application based snapcraft with classic fallback.""" + app = create_app() + try: return app.run() except errors.ClassicFallback: diff --git a/snapcraft/commands/__init__.py b/snapcraft/commands/__init__.py index 63deaa164c..6f4195c5c9 100644 --- a/snapcraft/commands/__init__.py +++ b/snapcraft/commands/__init__.py @@ -17,10 +17,13 @@ """Snapcraft commands.""" from . import core22, legacy +from .extensions import ExpandExtensions, ListExtensions from .lifecycle import SnapCommand __all__ = [ "core22", "legacy", "SnapCommand", + "ExpandExtensions", + "ListExtensions", ] diff --git a/snapcraft/commands/extensions.py b/snapcraft/commands/extensions.py new file mode 100644 index 0000000000..e08d30d926 --- /dev/null +++ b/snapcraft/commands/extensions.py @@ -0,0 +1,29 @@ +# -*- 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 . + +"""Extension commands for core24 (forwarded to the shared core22 implementation).""" + +from craft_application.commands import AppCommand + +from snapcraft.commands import core22 + + +class ExpandExtensions(AppCommand, core22.ExpandExtensionsCommand): + """core24 command to expand extensions.""" + + +class ListExtensions(AppCommand, core22.ListExtensionsCommand): + """core24 command to list extensions.""" diff --git a/tests/unit/commands/test_expand_extensions.py b/tests/unit/commands/test_expand_extensions.py index e795db84e6..5fcd36ff8d 100644 --- a/tests/unit/commands/test_expand_extensions.py +++ b/tests/unit/commands/test_expand_extensions.py @@ -15,28 +15,55 @@ # along with this program. If not, see . from argparse import Namespace +from dataclasses import dataclass from pathlib import Path from textwrap import dedent import pytest -import snapcraft.commands.core22 +from snapcraft import commands + + +@dataclass +class CoreData: + """Dataclass containing base info for a given core.""" + + base: str + build_base: str + grade: str + command_class: type + + +VALID_CORE_DATA = { + "core22": CoreData( + "core22", "core22", "stable", commands.core22.ExpandExtensionsCommand + ), + "core24": CoreData("core24", "devel", "devel", commands.ExpandExtensions), +} + + +@pytest.fixture(params=VALID_CORE_DATA.keys()) +def valid_core_data(request) -> CoreData: + """Fixture that provides valid base, build-base and grade values for each + coreXX base.""" + return VALID_CORE_DATA[request.param] @pytest.mark.usefixtures("fake_extension") -def test_expand_extensions_simple(new_dir, emitter): +def test_expand_extensions_simple(new_dir, emitter, valid_core_data): """Expand an extension for a simple snapcraft.yaml file.""" with Path("snapcraft.yaml").open("w") as yaml_file: print( dedent( - """\ + f"""\ name: test-name version: "0.1" summary: testing extensions description: expand a fake extension - base: core22 + base: {valid_core_data.base} + build-base: {valid_core_data.build_base} confinement: strict - grade: stable + grade: {valid_core_data.grade} apps: app1: @@ -52,18 +79,19 @@ def test_expand_extensions_simple(new_dir, emitter): file=yaml_file, ) - cmd = snapcraft.commands.core22.ExpandExtensionsCommand(None) + cmd = valid_core_data.command_class(None) cmd.run(Namespace()) emitter.assert_message( dedent( - """\ + f"""\ name: test-name version: '0.1' summary: testing extensions description: expand a fake extension - base: core22 + base: {valid_core_data.base} + build-base: {valid_core_data.build_base} confinement: strict - grade: stable + grade: {valid_core_data.grade} apps: app1: command: app1 @@ -84,7 +112,7 @@ def test_expand_extensions_simple(new_dir, emitter): @pytest.mark.usefixtures("fake_extension") -def test_expand_extensions_complex(new_dir, emitter, mocker): +def test_expand_extensions_complex(new_dir, emitter, mocker, valid_core_data): """Expand an extension for a complex snapcraft.yaml file. This includes parse-info, architectures, and advanced grammar. @@ -97,14 +125,15 @@ def test_expand_extensions_complex(new_dir, emitter, mocker): with Path("snapcraft.yaml").open("w") as yaml_file: print( dedent( - """\ + f"""\ name: test-name version: "0.1" summary: testing extensions description: expand a fake extension - base: core22 + base: {valid_core_data.base} + build-base: {valid_core_data.build_base} confinement: strict - grade: stable + grade: {valid_core_data.grade} architectures: [amd64, arm64, armhf] apps: @@ -128,18 +157,19 @@ def test_expand_extensions_complex(new_dir, emitter, mocker): file=yaml_file, ) - cmd = snapcraft.commands.core22.ExpandExtensionsCommand(None) + cmd = valid_core_data.command_class(None) cmd.run(Namespace()) emitter.assert_message( dedent( - """\ + f"""\ name: test-name version: '0.1' summary: testing extensions description: expand a fake extension - base: core22 + base: {valid_core_data.base} + build-base: {valid_core_data.build_base} confinement: strict - grade: stable + grade: {valid_core_data.grade} apps: app1: command: app1 diff --git a/tests/unit/commands/test_list_extensions.py b/tests/unit/commands/test_list_extensions.py index 25d42eb334..f8cfcd85b0 100644 --- a/tests/unit/commands/test_list_extensions.py +++ b/tests/unit/commands/test_list_extensions.py @@ -28,6 +28,7 @@ [ snapcraft.commands.core22.ListExtensionsCommand, snapcraft.commands.core22.ExtensionsCommand, + snapcraft.commands.ListExtensions, ], ) def test_command(emitter, command): @@ -38,7 +39,7 @@ def test_command(emitter, command): """\ Extension name Supported bases ---------------------- ---------------------- - fake-extension core22 + fake-extension core22, core24 flutter-beta core18 flutter-dev core18 flutter-master core18 @@ -72,6 +73,7 @@ def test_command(emitter, command): [ snapcraft.commands.core22.ListExtensionsCommand, snapcraft.commands.core22.ExtensionsCommand, + snapcraft.commands.ListExtensions, ], ) def test_command_extension_dups(emitter, command): diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ef057a36aa..d0f2d2dc1b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -71,7 +71,7 @@ class ExtensionImpl(extension.Extension): @staticmethod def get_supported_bases() -> Tuple[str, ...]: - return ("core22",) + return ("core22", "core24") @staticmethod def get_supported_confinement() -> Tuple[str, ...]: @@ -394,6 +394,7 @@ def default_project(extra_project_params): description="default project", base="core24", build_base="devel", + grade="devel", parts=parts, license="MIT", **extra_project_params, diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 69fde6adc0..0adc98f3ef 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Unit tests for application classes.""" +import json +from textwrap import dedent from typing import cast import pytest @@ -143,3 +145,69 @@ def test_build_planner_success_architecture_all(base, build_base, expected_base) assert build_info.platform == f"{expected_base.name}@{expected_base.version}" assert "all" not in [a.build_on for a in architectures] + + +@pytest.fixture() +def extension_source(default_project): + source = default_project.marshal() + source["confinement"] = "strict" + source["apps"] = { + "app1": { + "command": "app1", + "extensions": ["fake-extension"], + } + } + return source + + +@pytest.mark.usefixtures("fake_extension") +def test_application_expand_extensions(emitter, monkeypatch, extension_source, new_dir): + monkeypatch.setenv("CRAFT_DEBUG", "1") + + (new_dir / "snap").mkdir() + (new_dir / "snap/snapcraft.yaml").write_text(json.dumps(extension_source)) + + monkeypatch.setattr("sys.argv", ["snapcraft", "expand-extensions"]) + application.main() + emitter.assert_message( + dedent( + """\ + name: default + version: '1.0' + summary: default project + description: default project + base: core24 + build-base: devel + license: MIT + parts: + fake-extension/fake-part: + plugin: nil + confinement: strict + grade: devel + apps: + app1: + command: app1 + plugs: + - fake-plug + """ + ) + ) + + +@pytest.mark.usefixtures("fake_extension") +def test_application_build_with_extensions(monkeypatch, extension_source, new_dir): + """Test that extensions are correctly applied in regular builds.""" + monkeypatch.setenv("CRAFT_DEBUG", "1") + + (new_dir / "snap").mkdir() + (new_dir / "snap/snapcraft.yaml").write_text(json.dumps(extension_source)) + + # Calling a lifecycle command will create a Project. Creating a Project + # without applying the extensions will fail because the "extensions" field + # will still be present on the yaml data, so it's enough to run "pull". + monkeypatch.setattr("sys.argv", ["snapcraft", "pull", "--destructive-mode"]) + app = application.create_app() + app.run() + + project = app.get_project() + assert "fake-extension/fake-part" in project.parts