Skip to content
Open
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
16 changes: 16 additions & 0 deletions docs/reference/extensions/django-framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ For the project to make use of asynchronous Gunicorn workers:

- The ``requirements.txt`` or ``pyproject.toml`` file must include ``gevent`` as a dependency.

.. _reference-django-framework-uv:

uv projects
-----------

If both a ``uv.lock`` and a ``pyproject.toml`` file are present in the project
root, the extension builds the application with the :doc:`uv plugin
</reference/plugins/uv_plugin>` instead of the Python plugin, installing dependencies
from the lockfile with ``uv sync``. Gunicorn (``gunicorn~=23.0``) is injected
after the build step regardless of the lockfile contents. In this case a
``requirements.txt`` file is not required.

If only ``pyproject.toml`` is present (no ``uv.lock``), the extension falls back
to the Python plugin. If ``uv.lock`` is present but ``pyproject.toml`` is
missing, packing fails with an error, as the uv plugin requires both files.

.. _reference-django-framework-stage-packages:

App dependencies
Expand Down
16 changes: 16 additions & 0 deletions docs/reference/extensions/fastapi-framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ There are 2 requirements to be able to use the ``fastapi-framework`` extension:
directory or within a directory with the name of the rock as declared in
the project file.

.. _reference-fastapi-framework-uv:

uv projects
-----------

If both a ``uv.lock`` and a ``pyproject.toml`` file are present in the project
root, the extension builds the application with the :doc:`uv plugin
</reference/plugins/uv_plugin>` instead of the Python plugin, installing dependencies
from the lockfile with ``uv sync``. Uvicorn is injected after the build step
regardless of the lockfile contents. In this case a ``requirements.txt`` file is
not required.

If only ``pyproject.toml`` is present (no ``uv.lock``), the extension falls back
to the Python plugin. If ``uv.lock`` is present but ``pyproject.toml`` is
missing, packing fails with an error, as the uv plugin requires both files.

.. _reference-fastapi-framework-stage-packages:

App dependencies
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/extensions/flask-framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ For the project to make use of asynchronous Gunicorn workers:

- The ``requirements.txt`` or ``pyproject.toml`` file must include ``gevent`` as a dependency.

.. _reference-flask-framework-uv:

uv projects
-----------

If both a ``uv.lock`` and a ``pyproject.toml`` file are present in the project
root, the extension builds the application with the :doc:`uv plugin
</reference/plugins/uv_plugin>` instead of the Python plugin, installing dependencies
from the lockfile with ``uv sync``. Gunicorn (``gunicorn~=23.0``) is injected
after the build step regardless of the lockfile contents.

If only ``pyproject.toml`` is present (no ``uv.lock``), the extension falls back
to the Python plugin. If ``uv.lock`` is present but ``pyproject.toml`` is
missing, packing fails with an error, as the uv plugin requires both files.

.. _reference-flask-framework-stage-packages:

App dependencies
Expand Down
29 changes: 29 additions & 0 deletions rockcraft/extensions/_python_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from pathlib import Path
from typing import TypeAlias

from rockcraft.errors import ExtensionError

MatchFn: TypeAlias = Callable[[Path], str | None]


Expand Down Expand Up @@ -189,3 +191,30 @@ def find_entrypoint_with_factory(
)
module_path = _build_module_path(python_path)
return f"{module_path}:{factory_name}()"


def uses_uv(project_root: Path) -> bool:
"""Return True if the project should be built with the uv plugin.

A project is considered a uv project when both ``uv.lock`` and
``pyproject.toml`` are present in the project root.
"""
return (project_root / "uv.lock").exists() and (
project_root / "pyproject.toml"
).exists()


def validate_uv_lockfile(project_root: Path) -> None:
"""Validate uv lockfile consistency.

Raise an ``ExtensionError`` when ``uv.lock`` is present but
``pyproject.toml`` is missing, since the uv plugin requires both files.
"""
if (project_root / "uv.lock").exists() and not (
project_root / "pyproject.toml"
).exists():
raise ExtensionError(
"the plugin requires both uv.lock and pyproject.toml to be present",
doc_slug="/reference/plugins/uv_plugin",
logpath_report=False,
)
49 changes: 39 additions & 10 deletions rockcraft/extensions/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from rockcraft.extensions._utils import find_ubuntu_base_python_version
from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES

from ._python_utils import has_global_variable
from ._python_utils import has_global_variable, uses_uv, validate_uv_lockfile
from .app_parts import gen_logging_part
from .extension import Extension, _FrameworkFactory

Expand Down Expand Up @@ -118,14 +118,9 @@ def _get_parts(self) -> dict[str, Any]:
]

parts: dict[str, Any] = {
"fastapi-framework/dependencies": {
"plugin": "python",
"stage-packages": stage_packages,
"source": ".",
"python-packages": ["uvicorn"],
"python-requirements": ["requirements.txt"],
"build-environment": build_environment,
},
"fastapi-framework/dependencies": self._dependencies_part(
stage_packages, build_environment
),
"fastapi-framework/install-app": {
**self._get_install_app_part(),
"permissions": [{"owner": USER_UID, "group": USER_UID}],
Expand Down Expand Up @@ -213,6 +208,37 @@ def _app_prime(self) -> list[str]:
)
return user_prime

def _dependencies_part(
self, stage_packages: list[str], build_environment: list[Any]
) -> dict[str, Any]:
"""Return the part that installs the project's dependencies.

Uses the uv plugin if the project is using uv, otherwise uses the python plugin.
"""
if uses_uv(self.project_root):
return {
"plugin": "uv",
"stage-packages": stage_packages,
"source": ".",
"build-snaps": ["astral-uv"],
"build-environment": build_environment,
"override-build": (
"craftctl default\n"
"uv pip install "
"--python /usr/bin/python3 "
"--prefix ${CRAFT_PART_INSTALL} "
"uvicorn~=0.52"
),
}
return {
"plugin": "python",
"stage-packages": stage_packages,
"source": ".",
"python-packages": ["uvicorn~=0.52"],
"python-requirements": ["requirements.txt"],
"build-environment": build_environment,
}

def _asgi_path(self) -> str:
asgi_location = self._find_asgi_location()
return (
Expand Down Expand Up @@ -253,7 +279,10 @@ def _find_asgi_location(self) -> pathlib.Path:

def _check_project(self) -> None:
"""Ensure this extension can apply to the current rockcraft project."""
error_messages = self._requirements_txt_error_messages()
validate_uv_lockfile(self.project_root)
error_messages: list[str] = []
if not uses_uv(self.project_root):
error_messages = self._requirements_txt_error_messages()
if not self.yaml_data.get("services", {}).get("fastapi", {}).get("command"):
error_messages += self._asgi_entrypoint_error_messages()
if error_messages:
Expand Down
61 changes: 48 additions & 13 deletions rockcraft/extensions/gunicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
find_entrypoint_with_factory,
find_entrypoint_with_variable,
has_global_variable,
uses_uv,
validate_uv_lockfile,
)
from ._utils import find_ubuntu_base_python_version
from .app_parts import gen_logging_part
Expand Down Expand Up @@ -110,19 +112,10 @@ def _gen_parts(self) -> dict[str, Any]:
{"PARTS_PYTHON_INTERPRETER": f"python{python_version}"}
]

python_requirements: list[str] = []
if (self.project_root / "requirements.txt").exists():
python_requirements.append("requirements.txt")

parts: dict[str, Any] = {
f"{self.framework}-framework/dependencies": {
"plugin": "python",
"stage-packages": stage_packages,
"source": ".",
"python-packages": ["gunicorn~=23.0"],
"python-requirements": python_requirements,
"build-environment": build_environment,
},
f"{self.framework}-framework/dependencies": self._dependencies_part(
stage_packages, build_environment
),
f"{self.framework}-framework/install-app": {
**self.gen_install_app_part(),
"permissions": [{"owner": USER_UID, "group": USER_UID}],
Expand Down Expand Up @@ -287,6 +280,43 @@ def get_parts_snippet(self) -> dict[str, Any]:
"""Return the parts to add to parts."""
return {}

def _dependencies_part(
self, stage_packages: list[str], build_environment: list[Any]
) -> dict[str, Any]:
"""Return the part that installs the project's dependencies.

Uses the uv plugin if the project is using uv, otherwise uses the python plugin.
"""
if uses_uv(self.project_root):
return {
"plugin": "uv",
"stage-packages": stage_packages,
"source": ".",
"build-snaps": ["astral-uv"],
"build-environment": build_environment,
"override-build": (
"craftctl default\n"
"uv pip install "
"--python /usr/bin/python3 "
"--prefix ${CRAFT_PART_INSTALL} "
"gunicorn~=23.0"
),
}

python_requirements = (
["requirements.txt"]
if (self.project_root / "requirements.txt").exists()
else []
)
return {
"plugin": "python",
"stage-packages": stage_packages,
"source": ".",
"python-packages": ["gunicorn~=23.0"],
"python-requirements": python_requirements,
"build-environment": build_environment,
}


class FlaskFramework(_GunicornBase):
"""An extension for constructing Python applications based on the Flask framework."""
Expand Down Expand Up @@ -453,6 +483,7 @@ def _requirements_error_messages(self) -> list[str]:
@override
def check_project(self) -> None:
"""Ensure this extension can apply to the current rockcraft project."""
validate_uv_lockfile(self.project_root)
error_messages = self._requirements_error_messages()
if not self.yaml_data.get("services", {}).get("flask", {}).get("command"):
error_messages += self._wsgi_path_error_messages()
Expand Down Expand Up @@ -552,7 +583,11 @@ def _module_from_wsgi_file(self, wsgi_file: Path) -> str:
@override
def check_project(self) -> None:
"""Ensure this extension can apply to the current rockcraft project."""
if not (self.project_root / "requirements.txt").exists():
validate_uv_lockfile(self.project_root)
if (
not uses_uv(self.project_root)
and not (self.project_root / "requirements.txt").exists()
):
raise ExtensionError(
"missing requirements.txt file, django-framework extension "
"requires this file with Django specified as a dependency",
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/extensions/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,48 @@ def test_fastapi_extension_incorrect_prime_prefix_error(tmp_path, fastapi_input_
assert "start with app/" in str(exc)


def test_fastapi_extension_uv(tmp_path, fastapi_extension, fastapi_input_yaml):
(tmp_path / "pyproject.toml").write_text(
"[project]\nname = 'foo-bar'\nversion = '0.1.0'\ndependencies = ['fastapi']\n"
)
(tmp_path / "uv.lock").write_text("version = 1\n")
(tmp_path / "app.py").write_text("app = object()")

applied = extensions.apply_extensions(tmp_path, fastapi_input_yaml)

deps = applied["parts"]["fastapi-framework/dependencies"]
assert deps["plugin"] == "uv"
assert deps["source"] == "."
assert "python-packages" not in deps
assert "python-requirements" not in deps
assert deps["override-build"] == (
"craftctl default\n"
"uv pip install --python ${CRAFT_PART_INSTALL}/bin/python uvicorn"
)


def test_fastapi_extension_uv_no_requirements_txt_is_ok(
tmp_path, fastapi_extension, fastapi_input_yaml
):
(tmp_path / "pyproject.toml").write_text(
"[project]\nname = 'foo-bar'\nversion = '0.1.0'\ndependencies = ['fastapi']\n"
)
(tmp_path / "uv.lock").write_text("version = 1\n")
(tmp_path / "app.py").write_text("app = object()")

# Should not raise despite there being no requirements.txt.
extensions.apply_extensions(tmp_path, fastapi_input_yaml)


def test_fastapi_extension_uv_lock_without_pyproject_errors(
tmp_path, fastapi_extension, fastapi_input_yaml
):
(tmp_path / "uv.lock").write_text("version = 1\n")
(tmp_path / "app.py").write_text("app = object()")

with pytest.raises(ExtensionError) as exc:
extensions.apply_extensions(tmp_path, fastapi_input_yaml)
assert "both uv.lock and pyproject.toml" in str(exc.value)
def test_factory_dispatch_v1(tmp_path):
"""Factory returns FastAPIFramework (V1) for ubuntu@24.04."""
instance = extensions.FastAPIFrameworkFactory(
Expand Down
Loading
Loading