diff --git a/docs/reference/plugins.rst b/docs/reference/plugins.rst index d00ecde67c..9b9e76368f 100644 --- a/docs/reference/plugins.rst +++ b/docs/reference/plugins.rst @@ -21,6 +21,7 @@ Snapcraft. /common/craft-parts/reference/plugins/meson_plugin /common/craft-parts/reference/plugins/nil_plugin /common/craft-parts/reference/plugins/npm_plugin + plugins/poetry_plugin plugins/python_plugin /common/craft-parts/reference/plugins/qmake_plugin /common/craft-parts/reference/plugins/rust_plugin diff --git a/docs/reference/plugins/_python_common.rst b/docs/reference/plugins/_python_common.rst new file mode 100644 index 0000000000..f894647c10 --- /dev/null +++ b/docs/reference/plugins/_python_common.rst @@ -0,0 +1,17 @@ + +Dependencies +------------ + +Whether the Python interpreter needs to be included in the snap depends on its +``confinement``. Specifically: + +- Projects with ``strict`` or ``devmode`` confinement can safely use the base + snap's interpreter, so they typically do **not** need to include Python. +- Projects with ``classic`` confinement **cannot** use the base snap's + interpreter and thus must always bundle it (typically via ``stage-packages``). +- In both cases, a specific/custom Python installation can always be included + in the snap. This can be useful, for example, when using a different Python + version or building an interpreter with custom flags. + +Snapcraft will prefer an included interpreter over the base's, even for projects +with ``strict`` and ``devmode`` confinement. diff --git a/docs/reference/plugins/poetry_plugin.rst b/docs/reference/plugins/poetry_plugin.rst new file mode 100644 index 0000000000..0823c4e9ba --- /dev/null +++ b/docs/reference/plugins/poetry_plugin.rst @@ -0,0 +1,7 @@ +.. include:: /common/craft-parts/reference/plugins/poetry_plugin.rst + :end-before: .. _poetry-details-begin: + +.. include:: _python_common.rst + +.. include:: /common/craft-parts/reference/plugins/poetry_plugin.rst + :start-after: .. _poetry-details-end: diff --git a/docs/reference/plugins/python_plugin.rst b/docs/reference/plugins/python_plugin.rst index f0cf5e76b5..eeeb5ce1ac 100644 --- a/docs/reference/plugins/python_plugin.rst +++ b/docs/reference/plugins/python_plugin.rst @@ -2,22 +2,7 @@ .. include:: /common/craft-parts/reference/plugins/python_plugin.rst :end-before: .. _python-details-begin: -Dependencies ------------- - -Whether the Python interpreter needs to be included in the snap depends on its -``confinement``. Specifically: - -- Projects with ``strict`` or ``devmode`` confinement can safely use the base - snap's interpreter, so they typically do **not** need to include Python. -- Projects with ``classic`` confinement **cannot** use the base snap's - interpreter and thus must always bundle it (typically via ``stage-packages``). -- In both cases, a specific/custom Python installation can always be included - in the snap. This can be useful, for example, when using a different Python - version or building an interpreter with custom flags. - -Snapcraft will prefer an included interpreter over the base's, even for projects -with ``strict`` and ``devmode`` confinement. +.. include:: _python_common.rst .. include:: /common/craft-parts/reference/plugins/python_plugin.rst :start-after: .. _python-details-end: diff --git a/snapcraft/application.py b/snapcraft/application.py index df84454fa8..f8fd99f980 100644 --- a/snapcraft/application.py +++ b/snapcraft/application.py @@ -135,9 +135,6 @@ def _register_default_plugins(self) -> None: """Register per application plugins when initializing.""" super()._register_default_plugins() - # poetry plugin needs integration work, see #5025 - craft_parts.plugins.unregister("poetry") - if self._known_core24: # dotnet is disabled for core24 and newer because it is pending a rewrite craft_parts.plugins.unregister("dotnet") diff --git a/snapcraft/parts/plugins/__init__.py b/snapcraft/parts/plugins/__init__.py index 6104dfed0c..b641244c53 100644 --- a/snapcraft/parts/plugins/__init__.py +++ b/snapcraft/parts/plugins/__init__.py @@ -22,6 +22,7 @@ from .flutter_plugin import FlutterPlugin from .kernel_plugin import KernelPlugin from .matter_sdk_plugin import MatterSdkPlugin +from .poetry_plugin import PoetryPlugin from .python_plugin import PythonPlugin from .register import get_plugins, register @@ -31,6 +32,7 @@ "FlutterPlugin", "MatterSdkPlugin", "KernelPlugin", + "PoetryPlugin", "PythonPlugin", "get_plugins", "register", diff --git a/snapcraft/parts/plugins/poetry_plugin.py b/snapcraft/parts/plugins/poetry_plugin.py new file mode 100644 index 0000000000..efb3804edf --- /dev/null +++ b/snapcraft/parts/plugins/poetry_plugin.py @@ -0,0 +1,30 @@ +# -*- 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 . + +"""The Snapcraft Poetry plugin.""" + +from craft_parts.plugins import poetry_plugin +from overrides import override + +from snapcraft.parts.plugins import python_common + + +class PoetryPlugin(poetry_plugin.PoetryPlugin): + """A Poetry plugin for Snapcraft.""" + + @override + def _get_system_python_interpreter(self) -> str | None: + return python_common.get_system_interpreter(self._part_info) diff --git a/snapcraft/parts/plugins/python_common.py b/snapcraft/parts/plugins/python_common.py new file mode 100644 index 0000000000..f9c4a7e525 --- /dev/null +++ b/snapcraft/parts/plugins/python_common.py @@ -0,0 +1,96 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# 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 +# 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 . + +"""Common functionality for Python-based plugins. + +This plugin extends Craft-parts' vanilla Python plugin to properly +set the Python interpreter according to the Snapcraft base and +confinement parameters. +""" + +import logging +from pathlib import Path + +from craft_parts import PartInfo, StepInfo, errors + +logger = logging.getLogger(__name__) + + +_CONFINED_PYTHON_PATH = { + "core22": "/usr/bin/python3.10", + "core24": "/usr/bin/python3.12", +} + + +def get_system_interpreter(part_info: PartInfo) -> str | None: + """Obtain the path to the system-provided python interpreter. + + :param part_info: The info of the part that is being built. + """ + base = part_info.project_base + confinement = part_info.confinement + + if confinement == "classic" or base == "bare": + # classic snaps, and snaps without bases, must always provision Python + interpreter = None + else: + # otherwise, we should always know which Python is present on the + # base. If this fails on a new base, update _CONFINED_PYTHON_PATH + interpreter = _CONFINED_PYTHON_PATH.get(base) + if interpreter is None: + brief = f"Don't know which interpreter to use for base {base}." + resolution = "Please contact the Snapcraft team." + raise errors.PartsError(brief=brief, resolution=resolution) + + logger.debug( + "Using python interpreter '%s' for base '%s', confinement '%s'", + interpreter, + base, + confinement, + ) + return interpreter + + +def post_prime(step_info: StepInfo) -> None: + """Perform Python-specific actions right before packing.""" + base = step_info.project_base + + if base in ("core20", "core22"): + # Only fix pyvenv.cfg on core24+ snaps + return + + root_path: Path = step_info.prime_dir + + pyvenv = root_path / "pyvenv.cfg" + if not pyvenv.is_file(): + return + + snap_path = Path(f"/snap/{step_info.project_name}/current") + new_home = f"home = {snap_path}" + + candidates = ( + step_info.part_install_dir, + step_info.stage_dir, + ) + + old_contents = contents = pyvenv.read_text() + for candidate in candidates: + old_home = f"home = {candidate}" + contents = contents.replace(old_home, new_home) + + if old_contents != contents: + logger.debug("Updating pyvenv.cfg to:\n%s", contents) + pyvenv.write_text(contents) diff --git a/snapcraft/parts/plugins/python_plugin.py b/snapcraft/parts/plugins/python_plugin.py index df20f19871..63a306f85d 100644 --- a/snapcraft/parts/plugins/python_plugin.py +++ b/snapcraft/parts/plugins/python_plugin.py @@ -16,84 +16,17 @@ """The Snapcraft Python plugin.""" -import logging -from pathlib import Path from typing import Optional -from craft_parts import StepInfo, errors from craft_parts.plugins import python_plugin from overrides import override -logger = logging.getLogger(__name__) - - -_CONFINED_PYTHON_PATH = { - "core22": "/usr/bin/python3.10", - "core24": "/usr/bin/python3.12", -} +from snapcraft.parts.plugins import python_common class PythonPlugin(python_plugin.PythonPlugin): - """A Python plugin for Snapcraft. - - This plugin extends Craft-parts' vanilla Python plugin to properly - set the Python interpreter according to the Snapcraft base and - confinement parameters. - """ + """A Python plugin for Snapcraft.""" @override def _get_system_python_interpreter(self) -> Optional[str]: - base = self._part_info.project_base - confinement = self._part_info.confinement - - if confinement == "classic" or base == "bare": - # classic snaps, and snaps without bases, must always provision Python - interpreter = None - else: - # otherwise, we should always know which Python is present on the - # base. If this fails on a new base, update _CONFINED_PYTHON_PATH - interpreter = _CONFINED_PYTHON_PATH.get(base) - if interpreter is None: - brief = f"Don't know which interpreter to use for base {base}." - resolution = "Please contact the Snapcraft team." - raise errors.PartsError(brief=brief, resolution=resolution) - - logger.debug( - "Using python interpreter '%s' for base '%s', confinement '%s'", - interpreter, - base, - confinement, - ) - return interpreter - - @classmethod - def post_prime(cls, step_info: StepInfo) -> None: - """Perform Python-specific actions right before packing.""" - base = step_info.project_base - - if base in ("core20", "core22"): - # Only fix pyvenv.cfg on core24+ snaps - return - - root_path: Path = step_info.prime_dir - - pyvenv = root_path / "pyvenv.cfg" - if not pyvenv.is_file(): - return - - snap_path = Path(f"/snap/{step_info.project_name}/current") - new_home = f"home = {snap_path}" - - candidates = ( - step_info.part_install_dir, - step_info.stage_dir, - ) - - old_contents = contents = pyvenv.read_text() - for candidate in candidates: - old_home = f"home = {candidate}" - contents = contents.replace(old_home, new_home) - - if old_contents != contents: - logger.debug("Updating pyvenv.cfg to:\n%s", contents) - pyvenv.write_text(contents) + return python_common.get_system_interpreter(self._part_info) diff --git a/snapcraft/parts/plugins/register.py b/snapcraft/parts/plugins/register.py index 5d578462a0..7f8b56c6c9 100644 --- a/snapcraft/parts/plugins/register.py +++ b/snapcraft/parts/plugins/register.py @@ -24,6 +24,7 @@ from .flutter_plugin import FlutterPlugin from .kernel_plugin import KernelPlugin from .matter_sdk_plugin import MatterSdkPlugin +from .poetry_plugin import PoetryPlugin from .python_plugin import PythonPlugin @@ -38,6 +39,7 @@ def get_plugins(core22: bool) -> dict[str, PluginType]: "flutter": FlutterPlugin, "python": PythonPlugin, "matter-sdk": MatterSdkPlugin, + "poetry": PoetryPlugin, } if core22: diff --git a/snapcraft/services/lifecycle.py b/snapcraft/services/lifecycle.py index 61f8f95472..f093468280 100644 --- a/snapcraft/services/lifecycle.py +++ b/snapcraft/services/lifecycle.py @@ -86,7 +86,7 @@ def setup(self) -> None: @overrides def post_prime(self, step_info: StepInfo) -> bool: """Run post-prime parts steps for Snapcraft.""" - from snapcraft.parts import plugins + from snapcraft.parts.plugins import python_common project = cast(models.Project, self._project) @@ -94,8 +94,8 @@ def post_prime(self, step_info: StepInfo) -> bool: plugin_name = project.parts[part_name]["plugin"] # Handle plugin-specific prime fixes - if plugin_name == "python": - plugins.PythonPlugin.post_prime(step_info) + if plugin_name in ("python", "poetry"): + python_common.post_prime(step_info) # Handle patch-elf diff --git a/tests/spread/core24/python-hello/poetry/snap/snapcraft.yaml b/tests/spread/core24/python-hello/poetry/snap/snapcraft.yaml new file mode 100644 index 0000000000..95d5a63e74 --- /dev/null +++ b/tests/spread/core24/python-hello/poetry/snap/snapcraft.yaml @@ -0,0 +1,14 @@ +name: python-hello-poetry +version: "1.0" +summary: simple python application +description: build a python application using core24 +base: core24 +confinement: strict + +apps: + python-hello-poetry: + command: bin/hello +parts: + hello: + plugin: poetry + source: src diff --git a/tests/spread/core24/python-hello/poetry/src/pyproject.toml b/tests/spread/core24/python-hello/poetry/src/pyproject.toml new file mode 100644 index 0000000000..eef363c29b --- /dev/null +++ b/tests/spread/core24/python-hello/poetry/src/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "hello" +version = "0.1.0" +description = "" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = "^3.10" +black = "^24.8.0" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +hello = "hello:main" diff --git a/tests/spread/core24/python-hello/task.yaml b/tests/spread/core24/python-hello/task.yaml index 2db4043b9e..47a434462b 100644 --- a/tests/spread/core24/python-hello/task.yaml +++ b/tests/spread/core24/python-hello/task.yaml @@ -8,6 +8,7 @@ systems: environment: PARAM/strict: "" PARAM/classic: "--classic" + PARAM/poetry: "" restore: | cd ./"${SPREAD_VARIANT}" diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/snap/snapcraft.yaml b/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/snap/snapcraft.yaml new file mode 100644 index 0000000000..b1d101bb25 --- /dev/null +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/snap/snapcraft.yaml @@ -0,0 +1,15 @@ +name: poetry-hello +version: "1.0" +summary: simple python application +description: build a python application using core22 +base: core22 +confinement: strict + +apps: + poetry-hello: + command: bin/hello + +parts: + hello: + plugin: poetry + source: src diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/src/hello/__init__.py b/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/src/hello/__init__.py new file mode 100644 index 0000000000..e3095b2229 --- /dev/null +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/src/hello/__init__.py @@ -0,0 +1,2 @@ +def main(): + print("hello world") diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/src/pyproject.toml b/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/src/pyproject.toml new file mode 100644 index 0000000000..eef363c29b --- /dev/null +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/poetry-hello/src/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "hello" +version = "0.1.0" +description = "" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = "^3.10" +black = "^24.8.0" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +hello = "hello:main" diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml b/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml index bbb9bbea02..01b9eb9aa2 100644 --- a/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml @@ -8,6 +8,7 @@ environment: SNAP/colcon_ros2_wrapper: colcon-ros2-wrapper SNAP/flutter: flutter-hello SNAP/python: python-hello + SNAP/poetry: poetry-hello SNAP/qmake: qmake-hello SNAP/maven: maven-hello SNAP/dotnet: dotnet-hello diff --git a/tests/unit/parts/plugins/test_python_plugin.py b/tests/unit/parts/plugins/test_python_plugin.py index d41f6d295a..ed357cd3fe 100644 --- a/tests/unit/parts/plugins/test_python_plugin.py +++ b/tests/unit/parts/plugins/test_python_plugin.py @@ -19,7 +19,7 @@ import pytest from craft_parts import Part, PartInfo, ProjectInfo, Step, StepInfo, errors -from snapcraft.parts.plugins import PythonPlugin +from snapcraft.parts.plugins import PythonPlugin, python_common @pytest.fixture @@ -224,7 +224,7 @@ def test_fix_pyvenv(new_dir, home_attr): step_info = StepInfo(part_info, Step.PRIME) - PythonPlugin.post_prime(step_info) + python_common.post_prime(step_info) new_contents = pyvenv.read_text() assert "home = /snap/test-snap/current/usr/bin" in new_contents diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index ad1dab8a0b..ea86a2b518 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -355,17 +355,6 @@ def test_application_dotnet_not_registered(base, build_base, snapcraft_yaml): assert "dotnet" not in craft_parts.plugins.get_registered_plugins() -@pytest.mark.parametrize("base", const.CURRENT_BASES) -def test_application_poetry_not_registered(base, snapcraft_yaml): - """poetry plugin is disabled for all bases.""" - snapcraft_yaml(base=base) - app = application.create_app() - - app._register_default_plugins() - - assert "poetry" not in craft_parts.plugins.get_registered_plugins() - - def test_default_command_integrated(monkeypatch, mocker, new_dir): """Test that for core24 projects we accept "pack" as the default command."""