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

[experimental] UV support for Python plugins #825

Closed
wants to merge 21 commits into from
Closed
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
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
226 changes: 226 additions & 0 deletions craft_parts/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@
from __future__ import annotations

import abc
import pathlib
import shutil
import textwrap
from copy import deepcopy
from typing import TYPE_CHECKING

from overrides import override
from pydantic import errors

from craft_parts.actions import ActionProperties

from .properties import PluginProperties
Expand Down Expand Up @@ -122,3 +128,223 @@ def _get_java_post_build_commands(self) -> list[str]:
# pylint: enable=line-too-long

return link_java + link_jars


class BasePythonPlugin(Plugin):
"""A base class for Python plugins.

Provides common methods for dealing with Python items.
"""

def __init__(
self, *, properties: PluginProperties, part_info: infos.PartInfo
) -> None:
super().__init__(properties=properties, part_info=part_info)
use_uv_attr_name = f"{properties.plugin}_use_uv"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a parameter like <plugin>-package-manager: uv (defaulting to pip) could allow us to use other package managers in the future (and avoid using a boolean property). There's also the option to have use-uv in build-attributes but that would not tie to Python-based plugins specifically.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that idea!

if not hasattr(properties, use_uv_attr_name):
raise AttributeError(
f"Plugin properties requires a {use_uv_attr_name!r} property"
)

@property
def _use_uv(self) -> bool:
"""Whether the plugin should use uv rather than venv and pip."""
return getattr(self._options, f"{self._options.plugin}_use_uv", False)

@override
def get_build_snaps(self) -> set[str]:
"""Return a set of required snaps to install in the build environment."""
if self._use_uv:
return {"astral-uv/latest/beta"}
return set()

@override
def get_pull_commands(self) -> list[str]:
commands = super().get_pull_commands()
if self._use_uv:
commands.append("snap alias astral-uv.uv uv || true")
return commands

@override
def get_build_packages(self) -> set[str]:
"""Return a set of required packages to install in the build environment.

Child classes that need to override this should extend the returned set.
"""
packages = {"findutils", "python3-dev"}
if not self._use_uv:
packages.add("python3-venv")
return packages

@override
def get_build_environment(self) -> dict[str, str]:
"""Return a dictionary with the environment to use in the build step.

Child classes that need to override this should extend the dictionary returned
by this class.
"""
return {
# Add PATH to the python interpreter we always intend to use with
# this plugin. It can be user overridden, but that is an explicit
# choice made by a user.
"PATH": f"{self._part_info.part_install_dir}/bin:${{PATH}}",
"PARTS_PYTHON_INTERPRETER": "python3",
"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()
venv_commands = (
[
f'uv venv ${{PARTS_PYTHON_VENV_ARGS}} "{venv_dir}"',
f'export UV_PYTHON="{venv_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"',
]
if self._use_uv
else [
f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{venv_dir}"'
]
)
return [
*venv_commands,
f'PARTS_PYTHON_VENV_INTERP_PATH="{venv_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"',
]

def _get_find_python_interpreter_commands(self) -> list[str]:
"""Get the commands that find a staged Python interpreter.
Comment on lines +222 to +223
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could have these large helper scripts available as a separate utility to be invoked at build time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been done separately.


These commands should, in bash, have a side-effect of creating a variable
called ``symlink_target`` containing the path to the relevant Python payload.
"""
python_interpreter = self._get_system_python_interpreter() or ""
return [
textwrap.dedent(
f"""\
# look for a provisioned python interpreter
opts_state="$(set +o|grep errexit)"
set +e
install_dir="{self._part_info.part_install_dir}/usr/bin"
stage_dir="{self._part_info.stage_dir}/usr/bin"

# look for the right Python version - if the venv was created with python3.10,
# look for python3.10
basename=$(basename $(readlink -f ${{PARTS_PYTHON_VENV_INTERP_PATH}}))
echo Looking for a Python interpreter called \\"${{basename}}\\" in the payload...
payload_python=$(find "$install_dir" "$stage_dir" -type f -executable -name "${{basename}}" -print -quit 2>/dev/null)

if [ -n "$payload_python" ]; then
# We found a provisioned interpreter, use it.
echo Found interpreter in payload: \\"${{payload_python}}\\"
installed_python="${{payload_python##{self._part_info.part_install_dir}}}"
if [ "$installed_python" = "$payload_python" ]; then
# Found a staged interpreter.
symlink_target="..${{payload_python##{self._part_info.stage_dir}}}"
else
# The interpreter was installed but not staged yet.
symlink_target="..$installed_python"
fi
else
# Otherwise use what _get_system_python_interpreter() told us.
echo "Python interpreter not found in payload."
symlink_target="{python_interpreter}"
fi

if [ -z "$symlink_target" ]; then
echo "No suitable Python interpreter found, giving up."
exit 1
fi

eval "${{opts_state}}"
"""
)
]

def _get_rewrite_shebangs_commands(self) -> list[str]:
"""Get the commands used to rewrite shebangs in the install dir.

This can be overridden by application-specific subclasses to control how Python
shebangs in the final environment should be handled.
"""
script_interpreter = self._get_script_interpreter()
return [
textwrap.dedent(
f"""\
find "{self._get_venv_directory()}" -type f -executable -print0 | xargs -0 \\
sed -i "1 s|^#\\!${{PARTS_PYTHON_VENV_INTERP_PATH}}.*$|{script_interpreter}|"
"""
)
]

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 {venv_dir}/bin",
f'rm "{venv_dir}"/bin/python*',
]
return [
'ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"',
f'find "{self._get_venv_directory()}/bin" -type l -executable -name "python*" -print0 | xargs -n1 -0 ln -sf "${{symlink_target}}"'
]

def _should_remove_symlinks(self) -> bool:
"""Configure executables symlink removal.

This method can be overridden by application-specific subclasses to control
whether symlinks in the virtual environment should be removed. Default is
False. If True, the venv-created symlinks to python* in bin/ will be
removed and will not be recreated.
"""
return False

def _get_system_python_interpreter(self) -> str | None:
"""Obtain the path to the system-provided python interpreter.

This method can be overridden by application-specific subclasses. It
returns the path to the Python that bin/python3 should be symlinked to
if Python is not part of the payload.
"""
return '$(readlink -f "$(which "${PARTS_PYTHON_INTERPRETER}")")'

def _get_script_interpreter(self) -> str:
"""Obtain the shebang line to use in Python scripts.

This method can be overridden by application-specific subclasses. It
returns the script interpreter to use in existing Python scripts.
"""
return "#!/usr/bin/env ${PARTS_PYTHON_INTERPRETER}"

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

@abc.abstractmethod
def _get_package_install_commands(self) -> list[str]:
"""Get the commands for installing the given package in the Python virtualenv.

A specific Python build system plugin should override this method to provide
the necessary commands.
"""

@override
def get_build_commands(self) -> list[str]:
"""Return a list of commands to run during the build step."""
return [
*self._get_create_venv_commands(),
*self._get_package_install_commands(),
*self._get_rewrite_shebangs_commands(),
*self._get_find_python_interpreter_commands(),
*self._get_handle_symlinks_commands(),
]
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
Loading
Loading