Skip to content

Commit

Permalink
feat: add uv plugin to charmcraft (#2050)
Browse files Browse the repository at this point in the history
Needs canonical/craft-parts#945

Closes #2040.
CRAFT-3816

---------

Co-authored-by: Alex Lowe <[email protected]>
  • Loading branch information
bepri and lengau authored Dec 20, 2024
1 parent 455880a commit 6d0d9ce
Show file tree
Hide file tree
Showing 18 changed files with 475 additions and 7 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ ipython_config.py
# Pycharm
.idea

# VS Code
.vscode

# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
Expand Down
1 change: 1 addition & 0 deletions charmcraft/parts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def get_app_plugins() -> dict[str, type[craft_parts.plugins.Plugin]]:
"poetry": plugins.PoetryPlugin,
"python": plugins.PythonPlugin,
"reactive": plugins.ReactivePlugin,
"uv": plugins.UvPlugin,
}


Expand Down
4 changes: 4 additions & 0 deletions charmcraft/parts/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from ._poetry import PoetryPlugin, PoetryPluginProperties
from ._python import PythonPlugin, PythonPluginProperties
from ._reactive import ReactivePlugin, ReactivePluginProperties
from ._uv import UvPlugin
from craft_parts.plugins.uv_plugin import UvPluginProperties

__all__ = [
"BundlePlugin",
Expand All @@ -33,4 +35,6 @@
"PythonPluginProperties",
"ReactivePlugin",
"ReactivePluginProperties",
"UvPlugin",
"UvPluginProperties",
]
66 changes: 66 additions & 0 deletions charmcraft/parts/plugins/_uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# For further info, check https://github.com/canonical/charmcraft
"""Charmcraft-specific uv plugin."""

from pathlib import Path

from craft_parts.plugins import uv_plugin
from overrides import override

from charmcraft import utils


class UvPlugin(uv_plugin.UvPlugin):
@override
def get_build_environment(self) -> dict[str, str]:
return utils.extend_python_build_environment(super().get_build_environment())

@override
def _get_venv_directory(self) -> Path:
return self._part_info.part_install_dir / "venv"

@override
def _get_pip(self) -> str:
return 'uv pip --python="${PARTS_PYTHON_VENV_INTERP_PATH}"'

@override
def _get_package_install_commands(self) -> list[str]:
# Find the `uv sync` command and modify it to not install the project
orig_cmds = super()._get_package_install_commands()
for idx, cmd in enumerate(orig_cmds):
if cmd.startswith("uv sync"):
orig_cmds[idx] += " --no-install-project"
break

return [
*orig_cmds,
*utils.get_charm_copy_commands(
self._part_info.part_build_dir, self._part_info.part_install_dir
),
]

@override
def _should_remove_symlinks(self) -> bool:
return True

@override
def get_build_commands(self) -> list[str]:
return [
*super().get_build_commands(),
*utils.get_venv_cleanup_commands(
self._get_venv_directory(), keep_bins=False
),
]
1 change: 1 addition & 0 deletions docs/reference/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ potentially the addition of further files using the :ref:`craft_parts_dump_plugi
/common/craft-parts/reference/plugins/nil_plugin
python_plugin
poetry_plugin
uv_plugin

.. warning::
Other plugins are available from :external+craft-parts:ref:`craft-parts <plugins>`,
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/plugins/uv-charmcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: my-charm
type: charm
title: My uv charm
summary: An operator charm using uv.
description: |
An operator charm that uses uv for its project.
base: [email protected]
platforms:
amd64:
parts:
my-charm:
source: .
plugin: uv
49 changes: 49 additions & 0 deletions docs/reference/plugins/uv_plugin.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
.. _craft_parts_uv_plugin:

uv plugin
=========

The uv plugin is designed for Python charms that use `uv`_ as the build system
and are written with the `Operator framework`_.

.. include:: /common/craft-parts/reference/plugins/uv_plugin.rst
:start-after: .. _craft_parts_uv_plugin-keywords:
:end-before: .. _craft_parts_uv_plugin-environment_variables:

python-keep-bins
~~~~~~~~~~~~~~~~
**Type**: boolean
**Default**: False

Whether to keep Python scripts in the virtual environment's :file:`bin`
directory.

.. include:: /common/craft-parts/reference/plugins/uv_plugin.rst
:start-after: .. _craft_parts_poetry_plugin-environment_variables:
:end-before: .. _uv-details-end:

How it works
------------

During the build step, the plugin performs the following actions:

#. It creates a virtual environment in the
:ref:`${CRAFT_PART_INSTALL}/venv <craft_parts_step_execution_environment>`
directory.
#. It runs :command:`uv sync` to install the packages referenced in the
:file:`pyproject.toml` and :file:`uv.lock` files, along with any optional
groups or extras specified.
#. It copies any existing :file:`src` and :file:`lib` directories from your
charm project into the final charm.

Example
-------

The following :file:`charmcraft.yaml` file can be used with a uv project to
craft a charm with Ubuntu 24.04 as its base:

.. literalinclude:: uv-charmcraft.yaml
:language: yaml


.. _uv: https://docs.astral.sh/uv/
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ dependencies = [
"craft-application~=4.7",
"craft-cli>=2.3.0",
"craft-grammar>=2.0.0",
"craft-parts>=2.2.0",
"craft-parts>=2.2.1",
"craft-providers>=2.0.0",
"craft-platforms~=0.5",
"craft-providers>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ craft-application==4.7.0
craft-archives==2.0.2
craft-cli==2.13.0
craft-grammar==2.0.1
craft-parts==2.2.0
craft-parts==2.2.1
craft-platforms==0.5.0
craft-providers==2.0.4
craft-store==3.1.0
Expand Down
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,3 +460,24 @@ def python_plugin(tmp_path: pathlib.Path):
return craft_parts.plugins.get_plugin(
part=part, part_info=part_info, properties=plugin_properties
)


@pytest.fixture
def uv_plugin(tmp_path: pathlib.Path):
project_dirs = craft_parts.ProjectDirs(work_dir=tmp_path)
spec = {"plugin": "uv", "source": str(tmp_path)}
plugin_properties = parts.plugins.UvPluginProperties.unmarshal(spec)
part_spec = craft_parts.plugins.extract_part_properties(spec, plugin_name="uv")
part = craft_parts.Part(
"foo", part_spec, project_dirs=project_dirs, plugin_properties=plugin_properties
)
project_info = craft_parts.ProjectInfo(
application_name="test",
project_dirs=project_dirs,
cache_dir=tmp_path,
)
part_info = craft_parts.PartInfo(project_info, part=part)

return craft_parts.plugins.get_plugin(
part=part, part_info=part_info, properties=plugin_properties
)
100 changes: 100 additions & 0 deletions tests/integration/parts/plugins/test_uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# For further info, check https://github.com/canonical/charmcraft

import platform
import subprocess
import sys
from pathlib import Path
from typing import Any

import distro
import pytest
from craft_application import util as app_util

from charmcraft import services
from charmcraft.models import project

pytestmark = [
pytest.mark.skipif(sys.platform != "linux", reason="craft-parts is linux-only")
]


@pytest.fixture
def charm_project(basic_charm_dict: dict[str, Any], project_path: Path, request):
return project.PlatformCharm.unmarshal(
basic_charm_dict
| {
"base": f"{distro.id()}@{distro.version()}",
"platforms": {app_util.get_host_architecture(): None},
"parts": {
"my-charm": {
"plugin": "uv",
"source": str(project_path),
"source-type": "local",
}
},
}
)


@pytest.fixture
def uv_project(project_path: Path, monkeypatch) -> None:
subprocess.run(
[
"uv",
"init",
"--name=test-charm",
f"--python={platform.python_version()}",
"--no-progress",
"--no-workspace",
],
cwd=project_path,
check=True,
)
subprocess.run(["uv", "add", "ops"], cwd=project_path, check=True)
monkeypatch.delenv("UV_FROZEN", raising=False)
subprocess.run(
[
"uv",
"lock",
],
cwd=project_path,
check=True,
)
source_dir = project_path / "src"
source_dir.mkdir()
(source_dir / "charm.py").write_text("# Charm file")


@pytest.mark.slow
@pytest.mark.usefixtures("uv_project")
def test_uv_plugin(
build_plan, service_factory: services.CharmcraftServiceFactory, tmp_path: Path
):
install_path = tmp_path / "parts" / "my-charm" / "install"
stage_path = tmp_path / "stage"
service_factory.lifecycle._build_plan = build_plan

service_factory.lifecycle.run("stage")

# Check that the part install directory looks correct.
assert (install_path / "src" / "charm.py").read_text() == "# Charm file"
assert (install_path / "venv" / "lib").is_dir()

# Check that the stage directory looks correct.
assert (stage_path / "src" / "charm.py").read_text() == "# Charm file"
assert (stage_path / "venv" / "lib").is_dir()
assert not (stage_path / "venv" / "lib64").is_symlink()
16 changes: 16 additions & 0 deletions tests/spread/smoketests/uv/charmcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
type: charm
name: test-charm
summary: test-charm
description: test-charm

base: [email protected]
platforms:
amd64:
arm64:
riscv64:

parts:
my-part:
plugin: uv
source: .
build-snaps: [astral-uv]
6 changes: 6 additions & 0 deletions tests/spread/smoketests/uv/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from uv!")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions tests/spread/smoketests/uv/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[project]
name = "testcharm"
version = "0.1.0"
description = "a revolutionary charm"
requires-python = ">=3.10"
dependencies = ["overrides", "ops"]
9 changes: 9 additions & 0 deletions tests/spread/smoketests/uv/task.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
summary: pack a charm with uv

restore: |
rm -rf ./*.charm
execute: |
charmcraft pack 2>&1
CHARM_OUTPUT=$(find . -type f -name "*.charm")
charmcraft analyse $CHARM_OUTPUT
Loading

0 comments on commit 6d0d9ce

Please sign in to comment.