Skip to content

Commit

Permalink
experimental: uv support for Python plugins
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau committed Aug 21, 2024
1 parent ce5dc9a commit 16fe8fe
Show file tree
Hide file tree
Showing 9 changed files with 216 additions and 75 deletions.
43 changes: 41 additions & 2 deletions craft_parts/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,45 @@ class BasePythonPlugin(Plugin):
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"}
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.
"""
return {"findutils", "python3-dev", "python3-venv"}
packages = {"findutils", "python3-dev"}
if not self._use_uv:
packages.add("python3-venv")
return packages

@override
def get_build_environment(self) -> dict[str, str]:
Expand Down Expand Up @@ -178,8 +205,18 @@ def _get_venv_directory(self) -> pathlib.Path:
def _get_create_venv_commands(self) -> list[str]:
"""Get the commands for setting up the virtual environment."""
venv_dir = self._part_info.part_install_dir
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 [
f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{venv_dir}"',
*venv_commands,
f'PARTS_PYTHON_VENV_INTERP_PATH="{venv_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"',
]

Expand Down Expand Up @@ -286,6 +323,8 @@ def _get_script_interpreter(self) -> str:

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

@abc.abstractmethod
Expand Down
1 change: 1 addition & 0 deletions craft_parts/plugins/poetry_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class PoetryPluginProperties(PluginProperties, frozen=True):
title="Optional dependency groups",
description="optional dependency groups to include when installing.",
)
poetry_use_uv: bool = False

# part properties required by the plugin
source: str # pyright: ignore[reportGeneralTypeIssues]
Expand Down
1 change: 1 addition & 0 deletions craft_parts/plugins/python_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class PythonPluginProperties(PluginProperties, frozen=True):
python_requirements: list[str] = []
python_constraints: list[str] = []
python_packages: list[str] = ["pip", "setuptools", "wheel"]
python_use_uv: bool = False

# part properties required by the plugin
source: str # pyright: ignore[reportGeneralTypeIssues]
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ lint.ignore = [
[tool.ruff.lint.flake8-annotations]
allow-star-arg-any = true

[tool.ruff.lint.pydocstyle]
ignore-decorators = [ # Functions with these decorators don't have to have docstrings.
"typing.overload", # Default configuration
# The next four are all variations on override, so child classes don't have to repeat parent classes' docstrings.
"overrides.override",
"overrides.overrides",
"typing.override",
"typing_extensions.override",
]

[tool.ruff.lint.pylint]
max-args = 8
max-branches = 16
Expand Down
53 changes: 39 additions & 14 deletions tests/integration/plugins/test_poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,23 @@ def teardown_module():
plugins.unregister_all()


@pytest.fixture(params=[False, True])
def use_uv(request):
return request.param


@pytest.fixture(params=["test_poetry"])
def source_directory(request):
return pathlib.Path(__file__).parent / request.param


@pytest.fixture
def poetry_part(source_directory):
return {"source": str(source_directory), "plugin": "poetry"}
def poetry_part(source_directory, use_uv):
return {
"source": str(source_directory),
"plugin": "poetry",
"poetry-use-uv": use_uv,
}


@pytest.fixture
Expand Down Expand Up @@ -201,13 +210,19 @@ def _get_script_interpreter(self) -> str:
with lf.action_executor() as ctx:
ctx.execute(actions)

primed_script = pathlib.Path(lf.project_info.prime_dir, "bin/pip")
primed_script = pathlib.Path(lf.project_info.prime_dir, "bin/mytest")
assert primed_script.open().readline().rstrip() == "#!/my/script/interpreter"


def test_find_payload_python_bad_version(new_dir, partitions, parts_dict, poetry_part):
def test_find_payload_python_bad_version(
new_dir, partitions, parts_dict, poetry_part, use_uv
):
"""Test that the build fails if a payload interpreter is needed but it's the
wrong Python version."""
if use_uv:
poetry_part["build-environment"] = [
{"PARTS_PYTHON_VENV_ARGS": "--allow-existing"}
]
poetry_part["override-build"] = textwrap.dedent(
f"""\
# Put a binary called "python3.3" in the payload
Expand All @@ -225,8 +240,7 @@ def _get_system_python_interpreter(self) -> str | None:

plugins.register({"poetry": MyPoetryPlugin})

real_python = pathlib.Path(sys.executable).resolve()
real_basename = real_python.name
pathlib.Path(sys.executable).resolve()

lf = LifecycleManager(
parts_dict,
Expand All @@ -243,18 +257,33 @@ def _get_system_python_interpreter(self) -> str | None:

output = out.read_text()
expected_text = textwrap.dedent(
f"""\
Looking for a Python interpreter called "{real_basename}" in the payload...
"""\
Python interpreter not found in payload.
No suitable Python interpreter found, giving up.
"""
)
assert expected_text in output


def test_find_payload_python_good_version(new_dir, partitions, parts_dict, poetry_part):
def test_find_payload_python_good_version(
new_dir, partitions, parts_dict, poetry_part, use_uv
):
"""Test that the build succeeds if a payload interpreter is needed, and it's
the right Python version."""
if use_uv:
# uv can get multiple Python versions - ensure we're using the same version.
python_version_str = ".".join(str(i) for i in sys.version_info[:3])
poetry_part["build-environment"] = [
{
"PARTS_PYTHON_VENV_ARGS": " ".join(
[
"--allow-existing",
f"--python={python_version_str}",
"--python-preference=only-system",
]
)
}
]

real_python = pathlib.Path(sys.executable).resolve()
real_basename = real_python.name
Expand Down Expand Up @@ -284,9 +313,5 @@ def test_find_payload_python_good_version(new_dir, partitions, parts_dict, poetr

output = out.read_text()
payload_python = (install_dir / f"usr/bin/{real_basename}").resolve()
expected_text = textwrap.dedent(
f"""\
Found interpreter in payload: "{payload_python}"
"""
)
expected_text = f'Found interpreter in payload: "{payload_python}"'
assert expected_text in output
36 changes: 23 additions & 13 deletions tests/integration/plugins/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ def teardown_module():
plugins.unregister_all()


def test_python_plugin(new_dir, partitions):
@pytest.fixture(params=[False, True])
def use_uv(request):
return request.param


def test_python_plugin(new_dir, partitions, use_uv):
"""Prime a simple python source."""
source_location = Path(__file__).parent / "test_python"

Expand All @@ -44,6 +49,7 @@ def test_python_plugin(new_dir, partitions):
foo:
plugin: python
source: {source_location}
python-use-uv: {use_uv}
"""
)
parts = yaml.safe_load(parts_yaml)
Expand All @@ -61,7 +67,7 @@ def test_python_plugin(new_dir, partitions):
assert primed_script.open().readline().rstrip() == "#!/usr/bin/env python3"


def test_python_plugin_with_pyproject_toml(new_dir, partitions):
def test_python_plugin_with_pyproject_toml(new_dir, partitions, use_uv):
"""Prime a simple python source."""
source_location = Path(__file__).parent / "test_python_pyproject_toml"

Expand All @@ -71,6 +77,7 @@ def test_python_plugin_with_pyproject_toml(new_dir, partitions):
foo:
plugin: python
source: {source_location}
python-use-uv: {use_uv}
"""
)
parts = yaml.safe_load(parts_yaml)
Expand All @@ -91,14 +98,15 @@ def test_python_plugin_with_pyproject_toml(new_dir, partitions):
assert primed_script.open().readline().rstrip() == "#!/usr/bin/env python3"


def test_python_plugin_symlink(new_dir, partitions):
def test_python_plugin_symlink(new_dir, partitions, use_uv):
"""Run in the standard scenario with no overrides."""
parts_yaml = textwrap.dedent(
"""\
f"""\
parts:
foo:
plugin: python
source: .
python-use-uv: {use_uv}
"""
)
parts = yaml.safe_load(parts_yaml)
Expand All @@ -120,7 +128,7 @@ def test_python_plugin_symlink(new_dir, partitions):
assert os.path.basename(python_link).startswith("python3")


def test_python_plugin_override_get_system_interpreter(new_dir, partitions):
def test_python_plugin_override_get_system_interpreter(new_dir, partitions, use_uv):
"""Override the system interpreter, link should use it."""

class MyPythonPlugin(craft_parts.plugins.plugins.PythonPlugin):
Expand All @@ -131,11 +139,12 @@ def _get_system_python_interpreter(self) -> str | None:
plugins.register({"python": MyPythonPlugin})

parts_yaml = textwrap.dedent(
"""\
f"""\
parts:
foo:
plugin: python
source: .
python-use-uv: {use_uv}
"""
)
parts = yaml.safe_load(parts_yaml)
Expand All @@ -155,7 +164,7 @@ def _get_system_python_interpreter(self) -> str | None:

@pytest.mark.parametrize("remove_symlinks", [(True), (False)])
def test_python_plugin_no_system_interpreter(
new_dir, partitions, remove_symlinks: bool # noqa: FBT001
new_dir, partitions, use_uv, remove_symlinks: bool # noqa: FBT001
):
"""Check that the build fails if a payload interpreter is needed but not found."""

Expand All @@ -173,11 +182,12 @@ def _should_remove_symlinks(self) -> bool:
plugins.register({"python": MyPythonPlugin})

parts_yaml = textwrap.dedent(
"""\
f"""\
parts:
foo:
plugin: python
source: .
python-use-uv: {use_uv}
"""
)
parts = yaml.safe_load(parts_yaml)
Expand All @@ -191,7 +201,7 @@ def _should_remove_symlinks(self) -> bool:
ctx.execute(actions)


def test_python_plugin_remove_symlinks(new_dir, partitions):
def test_python_plugin_remove_symlinks(new_dir, partitions, use_uv):
"""Override symlink removal."""

class MyPythonPlugin(craft_parts.plugins.plugins.PythonPlugin):
Expand Down Expand Up @@ -223,7 +233,7 @@ def _should_remove_symlinks(self) -> bool:
assert python_link.exists() is False


def test_python_plugin_fix_shebangs(new_dir, partitions):
def test_python_plugin_fix_shebangs(new_dir, partitions, use_uv):
"""Check if shebangs are properly fixed in scripts."""
parts_yaml = textwrap.dedent(
"""\
Expand All @@ -247,7 +257,7 @@ def test_python_plugin_fix_shebangs(new_dir, partitions):
assert primed_script.open().readline().rstrip() == "#!/usr/bin/env python3"


def test_python_plugin_override_shebangs(new_dir, partitions):
def test_python_plugin_override_shebangs(new_dir, partitions, use_uv):
"""Override what we want in script shebang lines."""

class MyPythonPlugin(craft_parts.plugins.plugins.PythonPlugin):
Expand Down Expand Up @@ -294,7 +304,7 @@ def _get_script_interpreter(self) -> str:
"""


def test_find_payload_python_bad_version(new_dir, partitions):
def test_find_payload_python_bad_version(new_dir, partitions, use_uv):
"""Test that the build fails if a payload interpreter is needed but it's the
wrong Python version."""

Expand Down Expand Up @@ -337,7 +347,7 @@ def _get_system_python_interpreter(self) -> str | None:
assert expected_text in output


def test_find_payload_python_good_version(new_dir, partitions):
def test_find_payload_python_good_version(new_dir, partitions, use_uv):
"""Test that the build succeeds if a payload interpreter is needed, and it's
the right Python version."""

Expand Down
Loading

0 comments on commit 16fe8fe

Please sign in to comment.