-
Notifications
You must be signed in to change notification settings - Fork 40
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
Closed
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
f367943
feat(plugins): create base Python plugin (#819)
lengau 10f283d
feat(plugin): add poetry plugin
lengau c3e1b76
work: add a venv_dir
lengau 46b2434
fix test
lengau 52c26e1
chore: fix docs linting for new types
lengau 6383f07
ci: install poetry
lengau 360899f
ci: install poetry
lengau b421772
fix: use python venv dir
lengau ed49967
boop
lengau f5b20d9
ahem
lengau ff7ab68
doh
lengau 35816f1
in integration too
lengau 13cd175
chore: pr suggestions
lengau 8e34c56
fix: refactor poetry export and pip install commands
lengau f697bc0
lint: typing in poetry integration test
lengau e26c365
feat(plugins): create base Python plugin
lengau 1fc1119
experimental: uv support for Python plugins
lengau fcc596f
fix: different venv dir
lengau 27b6778
beta uv snap
lengau 296c417
fix
lengau e716f68
do all pythons
lengau File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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" | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 topip
) could allow us to use other package managers in the future (and avoid using a boolean property). There's also the option to haveuse-uv
inbuild-attributes
but that would not tie to Python-based plugins specifically.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like that idea!