Skip to content

Commit

Permalink
feat(plugin): add poetry plugin (#820)
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau authored Aug 26, 2024
1 parent f367943 commit ee47801
Show file tree
Hide file tree
Showing 12 changed files with 711 additions and 6 deletions.
19 changes: 18 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ jobs:
sudo apt install -y golang
# Install RPM dependencies for RPM tests
sudo apt install rpm
# Install poetry. From pipx on focal, from apt on newer systems.
if [[ $(grep VERSION_CODENAME /etc/os-release ) == "VERSION_CODENAME=focal" ]]; then
sudo apt-get install -y pipx
pipx install poetry
else
sudo apt-get install -y python3-poetry
fi
# Ensure we don't have dotnet installed, to properly test dotnet-deps
# Based on https://github.com/actions/runner-images/blob/main/images/linux/scripts/installers/dotnetcore-sdk.sh
sudo apt remove -y dotnet-* || true
Expand Down Expand Up @@ -168,7 +175,8 @@ jobs:
echo "::group::apt install"
sudo apt install -y ninja-build cmake scons qt5-qmake p7zip \
autoconf automake autopoint gcc git gperf help2man libtool texinfo \
curl findutils pkg-config golang rpm
curl findutils pkg-config golang rpm \
findutils python3-dev python3-venv
echo "::endgroup::"
echo "::group::dotnet removal"
# Ensure we don't have dotnet installed, to properly test dotnet-deps
Expand All @@ -182,6 +190,15 @@ jobs:
echo "::group::Wait for snap to complete"
snap watch --last=install
echo "::endgroup::"
echo "::group::Poetry"
# Install poetry. From pipx on focal, from apt on newer systems.
if [[ $(grep VERSION_CODENAME /etc/os-release ) == "VERSION_CODENAME=focal" ]]; then
sudo apt-get install -y pipx
pipx install poetry
else
sudo apt-get install -y python3-poetry
fi
echo "::endgroup::"
- name: specify node version
uses: actions/setup-node@v4
with:
Expand Down
22 changes: 17 additions & 5 deletions craft_parts/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

import abc
import pathlib
import textwrap
from copy import deepcopy
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -162,11 +163,21 @@ def get_build_environment(self) -> dict[str, str]:
"PARTS_PYTHON_VENV_ARGS": "",
}

def _get_venv_directory(self) -> pathlib.Path:
"""Get the directory into which the virtualenv should be placed.
This method can be overridden by application-specific subclasses to control
the location of the virtual environment if it should be a subdirectory of
the install dir.
"""
return self._part_info.part_install_dir

def _get_create_venv_commands(self) -> list[str]:
"""Get the commands for setting up the virtual environment."""
venv_dir = self._get_venv_directory()
return [
f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{self._part_info.part_install_dir}"',
f'PARTS_PYTHON_VENV_INTERP_PATH="{self._part_info.part_install_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"',
f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{venv_dir}"',
f'PARTS_PYTHON_VENV_INTERP_PATH="{venv_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"',
]

def _get_find_python_interpreter_commands(self) -> list[str]:
Expand Down Expand Up @@ -237,9 +248,10 @@ def _get_rewrite_shebangs_commands(self) -> list[str]:
def _get_handle_symlinks_commands(self) -> list[str]:
"""Get commands for handling Python symlinks."""
if self._should_remove_symlinks():
venv_dir = self._get_venv_directory()
return [
f"echo Removing python symlinks in {self._part_info.part_install_dir}/bin",
f'rm "{self._part_info.part_install_dir}"/bin/python*',
f"echo Removing python symlinks in {venv_dir}/bin",
f'rm "{venv_dir}"/bin/python*',
]
return ['ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"']

Expand Down Expand Up @@ -272,7 +284,7 @@ def _get_script_interpreter(self) -> str:

def _get_pip(self) -> str:
"""Get the pip command to use."""
return f"{self._part_info.part_install_dir}/bin/pip"
return f"{self._get_venv_directory()}/bin/pip"

@abc.abstractmethod
def _get_package_install_commands(self) -> list[str]:
Expand Down
2 changes: 2 additions & 0 deletions craft_parts/plugins/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .meson_plugin import MesonPlugin
from .nil_plugin import NilPlugin
from .npm_plugin import NpmPlugin
from .poetry_plugin import PoetryPlugin
from .properties import PluginProperties
from .python_plugin import PythonPlugin
from .qmake_plugin import QmakePlugin
Expand Down Expand Up @@ -58,6 +59,7 @@
"meson": MesonPlugin,
"nil": NilPlugin,
"npm": NpmPlugin,
"poetry": PoetryPlugin,
"python": PythonPlugin,
"qmake": QmakePlugin,
"rust": RustPlugin,
Expand Down
147 changes: 147 additions & 0 deletions craft_parts/plugins/poetry_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2020-2024 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 warranty of
# MERCHANTABILITY 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 <http://www.gnu.org/licenses/>.

"""The poetry plugin."""

import pathlib
import shlex
import subprocess
from typing import Literal

import pydantic
from overrides import override

from craft_parts.plugins import validator

from .base import BasePythonPlugin
from .properties import PluginProperties


class PoetryPluginProperties(PluginProperties, frozen=True):
"""The part properties used by the poetry plugin."""

plugin: Literal["poetry"] = "poetry"

poetry_with: set[str] = pydantic.Field(
default_factory=set,
title="Optional dependency groups",
description="optional dependency groups to include when installing.",
)

# part properties required by the plugin
source: str # pyright: ignore[reportGeneralTypeIssues]


class PoetryPluginEnvironmentValidator(validator.PluginEnvironmentValidator):
"""Check the execution environment for the Poetry plugin.
:param part_name: The part whose build environment is being validated.
:param env: A string containing the build step environment setup.
"""

_options: PoetryPluginProperties

@override
def validate_environment(
self, *, part_dependencies: list[str] | None = None
) -> None:
"""Ensure the environment has the dependencies to build Poetry applications.
:param part_dependencies: A list of the parts this part depends on.
"""
if "poetry-deps" in (part_dependencies or ()):
self.validate_dependency(
dependency="poetry",
plugin_name=self._options.plugin,
part_dependencies=part_dependencies,
)


class PoetryPlugin(BasePythonPlugin):
"""A plugin to build python parts."""

properties_class = PoetryPluginProperties
validator_class = PoetryPluginEnvironmentValidator
_options: PoetryPluginProperties

def _system_has_poetry(self) -> bool:
try:
poetry_version = subprocess.check_output(["poetry", "--version"], text=True)
except (subprocess.CalledProcessError, FileNotFoundError):
return False
return "Poetry" in poetry_version

@override
def get_build_packages(self) -> set[str]:
"""Return a set of required packages to install in the build environment."""
build_packages = super().get_build_packages()
if not self._system_has_poetry():
build_packages |= {"python3-poetry"}
return build_packages

def _get_poetry_export_commands(self, requirements_path: pathlib.Path) -> list[str]:
"""Get the commands for exporting from poetry.
Application-specific classes may override this if they need to export from
poetry differently.
:param requirements_path: The path of the requirements.txt file to write to.
:returns: A list of strings forming the export script.
"""
export_command = [
"poetry",
"export",
"--format=requirements.txt",
f"--output={requirements_path}",
"--with-credentials",
]
if self._options.poetry_with:
export_command.append(
f"--with={','.join(sorted(self._options.poetry_with))}",
)

return [shlex.join(export_command)]

def _get_pip_install_commands(self, requirements_path: pathlib.Path) -> list[str]:
"""Get the commands for installing with pip.
Application-specific classes my override this if they need to install
differently.
:param requirements_path: The path of the requirements.txt file to write to.
:returns: A list of strings forming the install script.
"""
pip = self._get_pip()
return [
# These steps need to be separate because poetry export defaults to including
# hashes, which don't work with installing from a directory.
f"{pip} install --requirement={requirements_path}",
# All dependencies should be installed through the requirements file made by
# poetry.
f"{pip} install --no-deps .",
# Check that the virtualenv is consistent.
f"{pip} check",
]

@override
def _get_package_install_commands(self) -> list[str]:
"""Return a list of commands to run during the build step."""
requirements_path = self._part_info.part_build_dir / "requirements.txt"

return [
*self._get_poetry_export_commands(requirements_path),
*self._get_pip_install_commands(requirements_path),
]
3 changes: 3 additions & 0 deletions docs/common/craft-parts/craft-parts.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ PluginNotStrict
PluginProperties
PluginPropertiesModel
PluginPullError
PoetryPlugin
PoetryPluginEnvironmentValidator
PoetryPluginProperties
Prepend
PrimeState
ProjectDirs
Expand Down
Loading

0 comments on commit ee47801

Please sign in to comment.