Skip to content

Commit

Permalink
feat: add Snapcraft-specific Poetry plugin (#5090)
Browse files Browse the repository at this point in the history
The plugin has the same behavior and restrictions as the Python plugin
with regards to base-dependent behavior, symlink handling, etc.

Therefore, this common behavior is extracted into a new "python_common"
module, used by both plugins. This approach is also taken for the
reference docs - the requirement of staging a Python interpreter (or
not) is the same for both plugins.

Enabled for core22 and core24.

Fixes #5025
  • Loading branch information
tigarmo authored Oct 4, 2024
1 parent 932f9e4 commit 5a42f0a
Show file tree
Hide file tree
Showing 20 changed files with 233 additions and 105 deletions.
1 change: 1 addition & 0 deletions docs/reference/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/plugins/_python_common.rst
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/reference/plugins/poetry_plugin.rst
Original file line number Diff line number Diff line change
@@ -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:
17 changes: 1 addition & 16 deletions docs/reference/plugins/python_plugin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
3 changes: 0 additions & 3 deletions snapcraft/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions snapcraft/parts/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -31,6 +32,7 @@
"FlutterPlugin",
"MatterSdkPlugin",
"KernelPlugin",
"PoetryPlugin",
"PythonPlugin",
"get_plugins",
"register",
Expand Down
30 changes: 30 additions & 0 deletions snapcraft/parts/plugins/poetry_plugin.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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)
96 changes: 96 additions & 0 deletions snapcraft/parts/plugins/python_common.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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)
73 changes: 3 additions & 70 deletions snapcraft/parts/plugins/python_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions snapcraft/parts/plugins/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -38,6 +39,7 @@ def get_plugins(core22: bool) -> dict[str, PluginType]:
"flutter": FlutterPlugin,
"python": PythonPlugin,
"matter-sdk": MatterSdkPlugin,
"poetry": PoetryPlugin,
}

if core22:
Expand Down
6 changes: 3 additions & 3 deletions snapcraft/services/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,16 @@ 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)

part_name = step_info.part_name
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

Expand Down
14 changes: 14 additions & 0 deletions tests/spread/core24/python-hello/poetry/snap/snapcraft.yaml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions tests/spread/core24/python-hello/poetry/src/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[tool.poetry]
name = "hello"
version = "0.1.0"
description = ""
authors = ["Your Name <[email protected]>"]

[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"
1 change: 1 addition & 0 deletions tests/spread/core24/python-hello/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ systems:
environment:
PARAM/strict: ""
PARAM/classic: "--classic"
PARAM/poetry: ""

restore: |
cd ./"${SPREAD_VARIANT}"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def main():
print("hello world")
Loading

0 comments on commit 5a42f0a

Please sign in to comment.