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

feat: add uv plugin to charmcraft #2050

Merged
merged 20 commits into from
Dec 20, 2024
Merged
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
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"
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume that this copying of the original source is a charmcraft thing?

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 believe so - I took this test directly from the existing poetry tests since the two are so similar.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It is - the charm isn't necessarily an installable module and needs to be included in the src directory.

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
Loading