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
1 change: 1 addition & 0 deletions charmcraft/parts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def get_app_plugins() -> dict[str, type[craft_parts.plugins.Plugin]]:
return {
"charm": plugins.CharmPlugin,
"poetry": plugins.PoetryPlugin,
"pylock": plugins.PylockPlugin,
"python": plugins.PythonPlugin,
"reactive": plugins.ReactivePlugin,
"uv": plugins.UvPlugin,
Expand Down
3 changes: 3 additions & 0 deletions charmcraft/parts/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from ._charm import CharmPlugin, CharmPluginProperties
from ._poetry import PoetryPlugin, PoetryPluginProperties
from ._pylock import PylockPlugin, PylockPluginProperties
from ._python import PythonPlugin, PythonPluginProperties
from ._reactive import ReactivePlugin, ReactivePluginProperties
from ._uv import UvPlugin
Expand All @@ -28,6 +29,8 @@
"CharmPluginProperties",
"PoetryPlugin",
"PoetryPluginProperties",
"PylockPlugin",
"PylockPluginProperties",
"PythonPlugin",
"PythonPluginProperties",
"ReactivePlugin",
Expand Down
141 changes: 141 additions & 0 deletions charmcraft/parts/plugins/_pylock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Copyright 2026 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 pylock plugin.

Builds a charm's virtualenv from a PEP 751 ``pylock.toml`` file. Unlike the
``uv`` and ``poetry`` plugins, this does not require the lock file's producing
tool to be present in the build environment: a ``pylock.toml`` produced by uv,
pdm, pip-tools or ``pip lock`` is installed with pip itself.

pip's ``-r pylock.toml`` support is experimental and landed in pip 26.1
(https://github.com/pypa/pip/pull/13876), so the plugin bootstraps a new enough
pip into the venv before installing.
"""

import re
import shlex
from pathlib import Path
from typing import Literal

import pydantic
from craft_parts.plugins.base import BasePythonPlugin
from craft_parts.plugins.properties import PluginProperties
from overrides import override

from charmcraft import utils

# PEP 751: a lock file must be named ``pylock.toml`` or ``pylock.<name>.toml``.
# pip detects the pylock format from this filename, so anything else is parsed
# as a requirements.txt and fails.
_PYLOCK_FILENAME = re.compile(r"^pylock\.(.+\.)?toml$")

# pip version that first understands ``pip install -r pylock.toml``.
_MIN_PIP = "26.1"


class PylockPluginProperties(PluginProperties, frozen=True):
"""The part properties used by the pylock plugin."""

plugin: Literal["pylock"] = "pylock"

pylock_file: str = "pylock.toml"
"""The PEP 751 lock file to install from, relative to the source tree."""

pylock_keep_bins: bool = False
"""Keep the virtual environment's 'bin' directory."""

source: str # pyright: ignore[reportGeneralTypeIssues]

@pydantic.field_validator("pylock_file", mode="after")
@classmethod
def _validate_pylock_file(cls, pylock_file: str) -> str:
if not _PYLOCK_FILENAME.match(Path(pylock_file).name):
raise ValueError(
f"{pylock_file!r} is not a valid PEP 751 lock file name: "
"the file must be named 'pylock.toml' or 'pylock.<name>.toml'."
)
return pylock_file


class PylockPlugin(BasePythonPlugin):
"""Charmcraft plugin to build a charm from a PEP 751 pylock.toml file."""

properties_class = PylockPluginProperties
_options: PylockPluginProperties # type: ignore[reportIncompatibleVariableOverride]

@override
def get_build_packages(self) -> set[str]:
# python3-pip provides the bootstrap pip; it is upgraded to >=26.1
# in the venv before the lock file is installed.
return {*super().get_build_packages(), "python3-pip"}

@override
def get_build_environment(self) -> dict[str, str]:
# NB: unlike the python/poetry/uv plugins this does *not* create the
# venv with --without-pip (it needs pip>=26.1 inside the venv to read
# pylock.toml) and does *not* set PIP_NO_BINARY: a pylock.toml pins
# specific artifacts with hashes, so forcing source builds would make
# the recorded wheel hashes fail to match.
return 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:
"""Get the pip command to use, run from the venv's own interpreter."""
return f'"{self._get_venv_directory()}/bin/python" -m pip'

@override
def _get_package_install_commands(self) -> list[str]:
"""Get the package installation commands.

Charms are not installable Python packages, so this installs only the
locked dependencies and then copies the charm source and charmlibs into
the install directory.
"""
pip = self._get_pip()
pylock_file = shlex.quote(self._options.pylock_file)
return [
# pylock support is experimental and only exists in pip>=26.1, which
# is newer than the pip shipped in any current build base.
f"{pip} install --upgrade 'pip>={_MIN_PIP}'",
f"{pip} install --requirement={pylock_file}",
# Confirm the resulting environment is internally consistent.
f"{pip} check",
*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_rewrite_shebangs_commands(self) -> list[str]:
"""Charms don't need their shebangs rewritten."""
return []

@override
def get_build_commands(self) -> list[str]:
return [
*super().get_build_commands(),
*utils.get_venv_cleanup_commands(
self._get_venv_directory(), keep_bins=self._options.pylock_keep_bins
),
]
131 changes: 131 additions & 0 deletions docs/howto/migrate-plugins/charm-to-pylock.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
.. _howto-migrate-to-pylock:

.. meta::
:description: How to migrate a charm from the Charm plugin to the pylock plugin in Charmcraft, using a PEP 751 pylock.toml lock file.

Migrate from the Charm plugin to the pylock plugin
==================================================

For charms that ship a `PEP 751`_ ``pylock.toml`` lock file, Charmcraft has a
:ref:`craft_parts_pylock_plugin`. This guide shows how to migrate from the
default Charm plugin to the pylock plugin.

The pylock plugin is tool-agnostic: it installs the lock file with pip, so the
tool that produced ``pylock.toml`` (uv, PDM, pip-tools, ...) doesn't need to be
present in the build environment. Like the other Python-family plugins, it
removes the need to maintain a hand-written ``requirements.txt``.

.. admonition:: Experimental
:class: important

pip's support for installing from ``pylock.toml`` is experimental and was
added in pip 26.1. The plugin upgrades pip in the build venv before
installing, but the behavior may change in future pip releases.

Update the project file
-----------------------

Update the project file to include the correct parts definition. If the charm
doesn't have an explicit ``parts`` section, create one as follows:

.. code-block:: yaml
:caption: charmcraft.yaml

parts:
my-charm: # This can be named anything you want
plugin: pylock
source: .

List the charm's dependencies
-----------------------------

List the charm's runtime dependencies wherever your locking tool reads them
from, typically the ``dependencies`` key of a :ref:`pyproject-toml-file`.

.. code-block:: toml
:caption: pyproject.toml
:emphasize-lines: 6-9

[project]
name = "my-charm"
version = "0.0.1"
requires-python = ">=3.10"

# Dependencies of the charm code.
dependencies = [
"ops>=3,<4",
]

List charm library dependencies
-------------------------------

Charm libraries are distributed either as regular Python packages under the
`charmlibs <https://documentation.ubuntu.com/charmlibs>`_ namespace, or hosted
on Charmhub. Python packages should be listed in the charm's dependencies.

Like the other Python-family plugins, the pylock plugin doesn't install
transitive dependencies for Charmhub-hosted libraries. If any of these charm
libraries have ``PYDEPS``, add them to the charm's dependencies.

To find library dependencies, check each loaded library file for its
``PYDEPS`` by running the following command at the root of the charm project:

.. code-block:: bash

find lib -name "*.py" -exec awk '/PYDEPS = \[/,/\]/' {} +

Add them to the ``dependencies`` key in ``pyproject.toml``.

Lock the dependencies
---------------------

Generate a :ref:`pylock-file` with whichever locking tool your project uses.
Make sure any extras or dependency groups you need are resolved into the lock
file at this point, since the plugin installs exactly what the lock file
records. For example:

.. code-block:: bash

uv export --format pylock.toml -o pylock.toml

Add the resulting ``pylock.toml`` to version control, so that your charm can be
built after a checkout by running ``charmcraft pack``.

If your lock file uses the alternate ``pylock.<name>.toml`` form, point the
plugin at it:

.. code-block:: yaml
:caption: charmcraft.yaml
:emphasize-lines: 5

parts:
my-charm:
plugin: pylock
source: .
pylock-file: pylock.production.toml

Include extra files
-------------------

The pylock plugin only includes the contents of the ``src`` and ``lib``
directories as well as the generated virtual environment. If other files such
as a charm's icon were previously included from the main directory, stage them
in a new part that uses the :ref:`craft_parts_dump_plugin`:

.. code-block:: yaml
:caption: charmcraft.yaml
:emphasize-lines: 5-10

parts:
my-charm:
plugin: pylock
source: .
version-file:
plugin: dump
source: .
stage:
- charm_version
- icon.svg


.. _PEP 751: https://peps.python.org/pep-0751/
6 changes: 4 additions & 2 deletions docs/howto/migrate-plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
Migrate to other plugins
========================

The Charm plugin has been superseded by the Poetry, Python, and uv plugins, each
providing benefits for its respective build system.
The Charm plugin has been superseded by the Poetry, Python, uv, and pylock
plugins, each providing benefits for its respective build system.

To migrate away from the Charm plugin, refer to the guide for your charm's build system:

- :ref:`howto-migrate-to-poetry`
- :ref:`howto-migrate-to-python`
- :ref:`howto-migrate-to-uv`
- :ref:`howto-migrate-to-pylock`

.. toctree::
:hidden:

Migrate to poetry <charm-to-poetry>
Migrate to python <charm-to-python>
Migrate to uv <charm-to-uv>
Migrate to pylock <charm-to-pylock>
2 changes: 2 additions & 0 deletions docs/reference/files/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Dependency management
- :ref:`pyproject-toml-file`
- :ref:`requirements-txt-file`
- :ref:`uv-lock-file`
- :ref:`pylock-file`


Testing
Expand Down Expand Up @@ -69,3 +70,4 @@ Testing
tests-integration-test-charm-py-file
tox-ini-file
uv-lock-file
pylock-file
38 changes: 38 additions & 0 deletions docs/reference/files/pylock-file.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.. _pylock-file:


``pylock.toml`` file
====================

A ``pylock.toml`` file in your charm's root directory specifies the exact
versions and artifact hashes of your charm's dependencies, in the
tool-agnostic format defined by `PEP 751`_.

.. seealso::

`PEP 751 – A file format to record Python dependencies for installation
reproducibility <https://peps.python.org/pep-0751/>`_

This file is required if your charm uses the :ref:`craft_parts_pylock_plugin`.

Unlike :ref:`uv.lock <uv-lock-file>`, ``pylock.toml`` isn't tied to a single
tool. You can generate it with whichever locker your project already uses, for
example:

.. code-block:: bash

uv export --format pylock.toml -o pylock.toml
# or
pdm lock --format pylock
# or
pip lock <your-requirements> -o pylock.toml

The file name must be ``pylock.toml`` or ``pylock.<name>.toml``; pip uses the
name to recognize the lock format.

Add this file to version control, so that your charm can be built after a
checkout by running ``charmcraft pack``. You shouldn't manually edit this file;
regenerate it with your locking tool when dependencies change.


.. _PEP 751: https://peps.python.org/pep-0751/
2 changes: 2 additions & 0 deletions docs/reference/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ environments and manipulate files.
- :ref:`craft_parts_python_plugin`
- :ref:`craft_parts_poetry_plugin`
- :ref:`craft_parts_uv_plugin`
- :ref:`craft_parts_pylock_plugin`
- :ref:`craft_parts_dump_plugin`
- :ref:`craft_parts_nil_plugin`

Expand All @@ -31,3 +32,4 @@ environments and manipulate files.
python_plugin
poetry_plugin
uv_plugin
pylock_plugin
Loading