diff --git a/craft_application/application.py b/craft_application/application.py index 888895687..bfbf2de83 100644 --- a/craft_application/application.py +++ b/craft_application/application.py @@ -558,6 +558,13 @@ def register_plugins(self, plugins: dict[str, PluginType]) -> None: """Register plugins for this application.""" if not plugins: return + + warnings.warn( + "Registering plugins through the Application is deprecated. Override " + "the Lifecycle service's get_plugin_group instead.", + DeprecationWarning, + stacklevel=0, + ) from craft_parts.plugins import register # noqa: PLC0415 craft_cli.emit.trace("Registering plugins...") diff --git a/craft_application/services/lifecycle.py b/craft_application/services/lifecycle.py index eced855be..14cb6783a 100644 --- a/craft_application/services/lifecycle.py +++ b/craft_application/services/lifecycle.py @@ -37,13 +37,17 @@ callbacks, ) from craft_parts.errors import CallbackRegistrationError +from craft_parts.plugins.plugins import set_plugin_group from typing_extensions import override from craft_application import errors, util +from craft_application.errors import EmptyBuildPlanError from craft_application.services import base from craft_application.util import repositories if TYPE_CHECKING: # pragma: no cover + from craft_parts.plugins import Plugin + from craft_application.application import AppMetadata from craft_application.services import ServiceFactory @@ -143,10 +147,53 @@ def __init__( @override def setup(self) -> None: """Initialize the LifecycleManager with previously-set arguments.""" + # By default we can get the regular build. However, if there is no possible + # build on the current machine, we get any build we can possibly do. + # If the exhaustive build plan is still empty, error out. + try: + build = self._get_build() + except EmptyBuildPlanError: + build_plan = self._services.get("build_plan").create_build_plan( + platforms=None, + build_for=None, + build_on=None, + ) + if not build_plan: + raise EmptyBuildPlanError + build = build_plan[0] + + plugin_group = self.get_plugin_group(build) + if plugin_group is not None: + emit.debug( + "A plugin group has been specified for the current build. " + "This overrides any previous plugin configurations.\n" + f"Build: {self._get_build()}\n" + f"Plugins: {plugin_group}" + ) + set_plugin_group(plugin_group) self._lcm = self._init_lifecycle_manager() callbacks.register_post_step(self.post_prime, step_list=[Step.PRIME]) callbacks.register_configure_overlay(repositories.enable_overlay_eol) + @staticmethod + def get_plugin_group( + build_info: craft_platforms.BuildInfo, # noqa: ARG004 (used by overrides) + ) -> dict[str, type[Plugin]] | None: + """Get the plugin group for a given build. + + If this returns a non-``None`` value, the LifecycleService sets the plugin + group to that group when running the given build. If ``None`` is returned, the + plugin groups feature is not used and an application must manually handle its + plugin groups. + + The default implementation simply returns ``None``, as this is designed for an + application to override in order to get the relevant plugin groups. + + :param build_info: The BuildInfo for the build. + :returns: A dictionary that is an appropriate plugin group or ``None``. + """ + return None + def _get_build(self) -> craft_platforms.BuildInfo: """Get the build for this run.""" plan = self._services.get("build_plan").plan() diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index 2c3f4fcac..2a869c18e 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -15,14 +15,21 @@ Changelog For a complete list of commits, check out the `1.2.3`_ release on GitHub. -6.1.0 (YYYY-MM-DD) +6.1.0 (unreleased) ------------------ +Services +======== + +- The Lifecycle service now has a ``get_plugin_group()`` method. + Application =========== - If a lifecycle command is run with ``--destructive-mode``, but without root, a warning will be emitted about potentially unexpected behavior. +- Direct registration of plugins in the Application is deprecated, as it's mutually + incompatible with the registration of plugin groups. For a complete list of commits, check out the `6.1.0`_ release on GitHub. @@ -513,8 +520,8 @@ Services - Setting the arguments for a service using the service factory's ``set_kwargs`` is deprecated. Use ``update_kwargs`` instead. -Testing -======= +Testing with pytest +=================== - Add a :doc:`pytest-plugin` with a fixture that enables production mode for the application if a test requires it. diff --git a/docs/reference/services/index.rst b/docs/reference/services/index.rst index 9157fafa5..c1d64a3a7 100644 --- a/docs/reference/services/index.rst +++ b/docs/reference/services/index.rst @@ -7,4 +7,5 @@ Services :maxdepth: 1 app + lifecycle project diff --git a/docs/reference/services/lifecycle.rst b/docs/reference/services/lifecycle.rst new file mode 100644 index 000000000..d6b2bd5e2 --- /dev/null +++ b/docs/reference/services/lifecycle.rst @@ -0,0 +1,21 @@ +.. meta:: + :description: API reference for the LifecycleService. In a craft application, the LifecycleService handles interactions with Craft Parts. + +.. py:currentmodule:: craft_application.services.lifecycle + +.. _reference-LifecycleService: + +``LifecycleService`` +==================== + +The ``LifecycleService`` handles interactions with :external+craft-parts:doc:`index`. + + +API documentation +----------------- + +.. autoclass:: LifecycleService + :member-order: bysource + :members: + :private-members: _get_build,_validate_build_plan,_get_build_for,_init_lifecycle_manager + :undoc-members: diff --git a/pyproject.toml b/pyproject.toml index 3a00e0e18..49ed592a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "craft-archives>=2.2.0", "craft-cli>=3.2.0", "craft-grammar>=2.3.0", - "craft-parts>=2.26.0", + "craft-parts>=2.28.0", "craft-platforms>=0.6.0", "craft-providers>=3.2.0", "Jinja2>=3.1.6,<4.0.0", diff --git a/tests/integration/services/test_lifecycle.py b/tests/integration/services/test_lifecycle.py index dc0f7e5d0..49d0a3657 100644 --- a/tests/integration/services/test_lifecycle.py +++ b/tests/integration/services/test_lifecycle.py @@ -17,11 +17,15 @@ import os import textwrap +from unittest import mock import craft_cli +import craft_parts import pytest import pytest_check from craft_application.services.lifecycle import LifecycleService +from craft_parts.plugins import plugins +from craft_parts.plugins.nil_plugin import NilPlugin @pytest.fixture( @@ -47,6 +51,33 @@ def parts_lifecycle(app_metadata, fake_project, fake_services, tmp_path, request return service +@pytest.mark.parametrize( + "plugin_group", + [ + *(pytest.param(group.value, id=group.name) for group in plugins.PluginGroup), + pytest.param({"nil": NilPlugin}, id="nil-only"), + ], +) +def test_setup_with_different_plugin_group( + tmp_path, app_metadata, fake_project, fake_services, plugin_group +): + try: + lifecycle_service = LifecycleService( + app=app_metadata, + services=fake_services, + work_dir=tmp_path, + cache_dir=tmp_path / "cache", + ) + + lifecycle_service.get_plugin_group = mock.Mock(return_value=plugin_group) + + lifecycle_service.setup() + + assert craft_parts.plugins.get_registered_plugins() == plugin_group + finally: + craft_parts.plugins.plugins.unregister_all() + + @pytest.mark.slow def test_run_and_clean_all_parts( parts_lifecycle: LifecycleService, build_plan_service, emitter, check, tmp_path diff --git a/tests/spread/witchcraft/plugin-groups/invalid-ubuntu@22.04/witchcraft.yaml b/tests/spread/witchcraft/plugin-groups/invalid-ubuntu@22.04/witchcraft.yaml new file mode 100644 index 000000000..42797df19 --- /dev/null +++ b/tests/spread/witchcraft/plugin-groups/invalid-ubuntu@22.04/witchcraft.yaml @@ -0,0 +1,18 @@ +name: merlin-build +description: | + A witchcraft cauldron that fails to pack on Ubuntu 22.04 due to the lack of a rust plugin. +base: ubuntu@22.04 +platforms: + platform-independent: + build-on: [amd64, arm64, ppc64el, s390x, riscv64] + build-for: [all] + +version: "0.1" + +parts: + part1: + # The rust plugin is only available for 24.04+ in witchcraft + plugin: rust + source: . + override-build: | + true diff --git a/tests/spread/witchcraft/plugin-groups/invalid-ubuntu@24.04/witchcraft.yaml b/tests/spread/witchcraft/plugin-groups/invalid-ubuntu@24.04/witchcraft.yaml new file mode 100644 index 000000000..66cae3be4 --- /dev/null +++ b/tests/spread/witchcraft/plugin-groups/invalid-ubuntu@24.04/witchcraft.yaml @@ -0,0 +1,18 @@ +name: morgana-build +description: | + A witchcraft cauldron that fails to pack on Ubuntu 24.04 because of the lack of a Python plugin. +base: ubuntu@24.04 +platforms: + platform-independent: + build-on: [amd64, arm64, ppc64el, s390x, riscv64] + build-for: [all] + +version: "0.1" + +parts: + part1: + # The Python plugin is only available for 22.04 and below in witchcraft. + plugin: python + source: . + override-build: | + true diff --git a/tests/spread/witchcraft/plugin-groups/task.yaml b/tests/spread/witchcraft/plugin-groups/task.yaml new file mode 100644 index 000000000..adc005790 --- /dev/null +++ b/tests/spread/witchcraft/plugin-groups/task.yaml @@ -0,0 +1,28 @@ +summary: test setting of plugin groups based on BuildInfo. + +systems: + - ubuntu-22.04-64 + - ubuntu-24.04-64 + +execute: | + . /etc/os-release + + export DISTRO="${ID}@${VERSION_ID}" + + # Check that the valid package for this system packs. + cd "${DISTRO}" + witchcraft pack --destructive-mode + cd - + + # Check that the package referencing a plugin not used on this system does not pack. + cd "invalid-${DISTRO}" + witchcraft pack --destructive-mode |& MATCH "Plugin '[a-z]+' in part 'part1' is not registered." + + +restore: | + for path in $(find ./* -maxdepth 0 -type d); do + cd "${path}" + witchcraft clean --destructive-mode || true + rm -f *.witchcraft + cd .. + done diff --git a/tests/spread/witchcraft/plugin-groups/ubuntu@22.04/witchcraft.yaml b/tests/spread/witchcraft/plugin-groups/ubuntu@22.04/witchcraft.yaml new file mode 100644 index 000000000..03c8183b6 --- /dev/null +++ b/tests/spread/witchcraft/plugin-groups/ubuntu@22.04/witchcraft.yaml @@ -0,0 +1,17 @@ +name: merlin-build +summary: A witchcraft cauldron that packs on Ubuntu 22.04 with the python plugin. +base: ubuntu@22.04 +platforms: + platform-independent: + build-on: [amd64, arm64, ppc64el, s390x, riscv64] + build-for: [all] + +version: "0.1" + +parts: + part1: + # The Python plugin is only available for 22.04 and below in witchcraft. + plugin: python + source: . + override-build: | + true diff --git a/tests/spread/witchcraft/plugin-groups/ubuntu@24.04/witchcraft.yaml b/tests/spread/witchcraft/plugin-groups/ubuntu@24.04/witchcraft.yaml new file mode 100644 index 000000000..5945953d0 --- /dev/null +++ b/tests/spread/witchcraft/plugin-groups/ubuntu@24.04/witchcraft.yaml @@ -0,0 +1,17 @@ +name: morgana-build +summary: A witchcraft cauldron that packs on Ubuntu 22.04 with the rust plugin. +base: ubuntu@24.04 +platforms: + platform-independent: + build-on: [amd64, arm64, ppc64el, s390x, riscv64] + build-for: [all] + +version: "0.1" + +parts: + part1: + # The rust plugin is only available for 24.04+ + plugin: rust + source: . + override-build: | + true diff --git a/uv.lock b/uv.lock index 3c90d2555..f835243cb 100644 --- a/uv.lock +++ b/uv.lock @@ -615,7 +615,7 @@ requires-dist = [ { name = "craft-archives", specifier = ">=2.2.0" }, { name = "craft-cli", specifier = ">=3.2.0" }, { name = "craft-grammar", specifier = ">=2.3.0" }, - { name = "craft-parts", specifier = ">=2.26.0" }, + { name = "craft-parts", specifier = ">=2.28.0" }, { name = "craft-platforms", specifier = ">=0.6.0" }, { name = "craft-providers", specifier = ">=3.2.0" }, { name = "distro-support", specifier = ">=2025.8.13" }, @@ -725,7 +725,7 @@ wheels = [ [[package]] name = "craft-parts" -version = "2.26.0" +version = "2.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lxml" }, @@ -737,9 +737,9 @@ dependencies = [ { name = "semver" }, { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-17-craft-application-dev-jammy' and extra == 'group-17-craft-application-dev-noble') or (extra == 'group-17-craft-application-dev-jammy' and extra == 'group-17-craft-application-dev-plucky') or (extra == 'group-17-craft-application-dev-jammy' and extra == 'group-17-craft-application-dev-questing') or (extra == 'group-17-craft-application-dev-jammy' and extra == 'group-17-craft-application-dev-resolute') or (extra == 'group-17-craft-application-dev-noble' and extra == 'group-17-craft-application-dev-plucky') or (extra == 'group-17-craft-application-dev-noble' and extra == 'group-17-craft-application-dev-questing') or (extra == 'group-17-craft-application-dev-noble' and extra == 'group-17-craft-application-dev-resolute') or (extra == 'group-17-craft-application-dev-plucky' and extra == 'group-17-craft-application-dev-questing') or (extra == 'group-17-craft-application-dev-plucky' and extra == 'group-17-craft-application-dev-resolute') or (extra == 'group-17-craft-application-dev-questing' and extra == 'group-17-craft-application-dev-resolute')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/0e/32408579efd9061321c8df73754f47347e0eb26c04621b61bf173b9e2fc3/craft_parts-2.26.0.tar.gz", hash = "sha256:53d73015ec55ca5ad5dfc84113db26b06191928bc82ac1e18e8582381390b6d6", size = 873295, upload-time = "2025-10-22T15:54:26.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/80/5d2ab6748840460d0d96ae95cf56be6d6c15c57266e4f2ced34ac0f8c44a/craft_parts-2.28.0.tar.gz", hash = "sha256:8de965c14272776c23e1a8836178448bf3dc9baa713a4bd2d68fe686885b093a", size = 887516, upload-time = "2026-01-09T15:03:42.634Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/10/1f1fd5db0fbe0bf4056d72e97119417e67200f76b092955ec14d16f8cac7/craft_parts-2.26.0-py3-none-any.whl", hash = "sha256:5c701293c8ebc1cfb3ab22fc4a5c89be590ca6b6f6259e62e213abbd4b0a4aac", size = 521872, upload-time = "2025-10-22T15:54:24.832Z" }, + { url = "https://files.pythonhosted.org/packages/cb/94/a0d43886e362cde5e19c87d60742d766feab0e41e980baa6e31be179b89b/craft_parts-2.28.0-py3-none-any.whl", hash = "sha256:92c839430986afbfb841cd1d58d705ca67a5ee8fee8daa750bef1819ff610a53", size = 529746, upload-time = "2026-01-09T15:03:41.025Z" }, ] [[package]] diff --git a/witchcraft/services/__init__.py b/witchcraft/services/__init__.py index 58dde7bc2..a87c3cb12 100644 --- a/witchcraft/services/__init__.py +++ b/witchcraft/services/__init__.py @@ -24,6 +24,11 @@ def register_services() -> None: This registers with the ServiceFactory all the services that witchcraft adds or overrides. """ + craft_application.ServiceFactory.register( + "lifecycle", + "Lifecycle", + module="witchcraft.services.lifecycle", + ) craft_application.ServiceFactory.register( "package", "PackageService", diff --git a/witchcraft/services/lifecycle.py b/witchcraft/services/lifecycle.py new file mode 100644 index 000000000..d575b8576 --- /dev/null +++ b/witchcraft/services/lifecycle.py @@ -0,0 +1,44 @@ +# This file is part of craft_application. +# +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""Witchcraft lifecycle service.""" + +import craft_platforms +from craft_application.services import lifecycle +from craft_parts.plugins import Plugin +from craft_parts.plugins.plugins import PluginGroup +from craft_parts.plugins.python_plugin import PythonPlugin +from craft_parts.plugins.rust_plugin import RustPlugin +from typing_extensions import override + +MERLIN = PluginGroup.MINIMAL.value | { + "python": PythonPlugin, +} +MORGANA = PluginGroup.MINIMAL.value | {"rust": RustPlugin} + + +class Lifecycle(lifecycle.LifecycleService): + """Lifecycle service for witchcraft.""" + + @override + @staticmethod + def get_plugin_group( + build_info: craft_platforms.BuildInfo, + ) -> dict[str, type[Plugin]] | None: + if build_info.build_base.distribution != "ubuntu": + return MORGANA + if build_info.build_base <= craft_platforms.DistroBase("ubuntu", "22.04"): + return MERLIN + return MORGANA