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."""