Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Snapcraft-specific Poetry plugin #5090

Merged
merged 1 commit into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <you@example.com>"]

[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
Loading