diff --git a/charmcraft/parts/__init__.py b/charmcraft/parts/__init__.py index a037c1400..d2d9c58b3 100644 --- a/charmcraft/parts/__init__.py +++ b/charmcraft/parts/__init__.py @@ -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, diff --git a/charmcraft/parts/plugins/__init__.py b/charmcraft/parts/plugins/__init__.py index 97a43a090..43d998b5c 100644 --- a/charmcraft/parts/plugins/__init__.py +++ b/charmcraft/parts/plugins/__init__.py @@ -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 @@ -28,6 +29,8 @@ "CharmPluginProperties", "PoetryPlugin", "PoetryPluginProperties", + "PylockPlugin", + "PylockPluginProperties", "PythonPlugin", "PythonPluginProperties", "ReactivePlugin", diff --git a/charmcraft/parts/plugins/_pylock.py b/charmcraft/parts/plugins/_pylock.py new file mode 100644 index 000000000..d27cc6dac --- /dev/null +++ b/charmcraft/parts/plugins/_pylock.py @@ -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..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..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 + ), + ] diff --git a/docs/howto/migrate-plugins/charm-to-pylock.rst b/docs/howto/migrate-plugins/charm-to-pylock.rst new file mode 100644 index 000000000..9f6161beb --- /dev/null +++ b/docs/howto/migrate-plugins/charm-to-pylock.rst @@ -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 `_ 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..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/ diff --git a/docs/howto/migrate-plugins/index.rst b/docs/howto/migrate-plugins/index.rst index 7c39feba6..5f94a09c5 100644 --- a/docs/howto/migrate-plugins/index.rst +++ b/docs/howto/migrate-plugins/index.rst @@ -3,14 +3,15 @@ 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: @@ -18,3 +19,4 @@ To migrate away from the Charm plugin, refer to the guide for your charm's build Migrate to poetry Migrate to python Migrate to uv + Migrate to pylock diff --git a/docs/reference/files/index.rst b/docs/reference/files/index.rst index 503b574fa..3b98a762c 100644 --- a/docs/reference/files/index.rst +++ b/docs/reference/files/index.rst @@ -35,6 +35,7 @@ Dependency management - :ref:`pyproject-toml-file` - :ref:`requirements-txt-file` - :ref:`uv-lock-file` +- :ref:`pylock-file` Testing @@ -69,3 +70,4 @@ Testing tests-integration-test-charm-py-file tox-ini-file uv-lock-file + pylock-file diff --git a/docs/reference/files/pylock-file.rst b/docs/reference/files/pylock-file.rst new file mode 100644 index 000000000..4cfba64f9 --- /dev/null +++ b/docs/reference/files/pylock-file.rst @@ -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 `_ + +This file is required if your charm uses the :ref:`craft_parts_pylock_plugin`. + +Unlike :ref:`uv.lock `, ``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 -o pylock.toml + +The file name must be ``pylock.toml`` or ``pylock..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/ diff --git a/docs/reference/plugins/index.rst b/docs/reference/plugins/index.rst index 723c25e1a..52a808101 100644 --- a/docs/reference/plugins/index.rst +++ b/docs/reference/plugins/index.rst @@ -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` @@ -31,3 +32,4 @@ environments and manipulate files. python_plugin poetry_plugin uv_plugin + pylock_plugin diff --git a/docs/reference/plugins/pylock-charmcraft.yaml b/docs/reference/plugins/pylock-charmcraft.yaml new file mode 100644 index 000000000..f08f30913 --- /dev/null +++ b/docs/reference/plugins/pylock-charmcraft.yaml @@ -0,0 +1,13 @@ +name: my-charm +type: charm +title: My pylock charm +summary: An operator charm using a PEP 751 lock file. +description: | + An operator charm that installs its dependencies from a pylock.toml file. +base: ubuntu@26.04 +platforms: + amd64: +parts: + my-charm: + source: . + plugin: pylock diff --git a/docs/reference/plugins/pylock_plugin.rst b/docs/reference/plugins/pylock_plugin.rst new file mode 100644 index 000000000..23751622b --- /dev/null +++ b/docs/reference/plugins/pylock_plugin.rst @@ -0,0 +1,81 @@ +.. _craft_parts_pylock_plugin: + +pylock plugin +============= + + See also: :ref:`howto-migrate-to-pylock` + +The pylock plugin is designed for Python charms written with the +`Operator framework`_ that ship a `PEP 751`_ ``pylock.toml`` lock file. + +Unlike the :ref:`uv ` and +:ref:`poetry ` plugins, the pylock plugin doesn't +require the tool that produced the lock file to be present in the build +environment. A ``pylock.toml`` produced by uv, PDM, pip-tools or +``pip lock`` is installed with pip itself, which makes the plugin +tool-agnostic. + +.. 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 to a new + enough version before installing, but the pip command line for lock files + may still change between releases. + +Keywords +-------- + +In addition to the :ref:`common part keywords `, the +pylock plugin provides the following plugin-specific keywords. + +pylock-file +~~~~~~~~~~~ + +**Type**: string +**Default**: ``pylock.toml`` + +The `PEP 751`_ lock file to install from, relative to the part's source. The +name must be ``pylock.toml`` or ``pylock..toml``; pip uses the file name +to recognize the lock format, so any other name is rejected. + +pylock-keep-bins +~~~~~~~~~~~~~~~~ + +**Type**: boolean +**Default**: False + +Whether to keep Python scripts in the virtual environment's :file:`bin` +directory. + +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 ` + directory. +#. It upgrades pip in that environment to a version that can install from a + ``pylock.toml`` file. +#. It runs :command:`pip install --requirement=pylock.toml` to install the + exact, hash-pinned packages recorded in the lock file, then runs + :command:`pip check` to confirm the environment is consistent. +#. It copies any existing :file:`src` and :file:`lib` directories from your + charm project into the final charm. + +Because a ``pylock.toml`` records exact versions and artifact hashes, the +plugin doesn't expose extras or dependency-group selection: lock the desired +set of packages when the lock file is generated. + +Example +------- + +The following project file can be used with a project that has a +``pylock.toml`` to craft a charm with Ubuntu 26.04 LTS as its base: + +.. literalinclude:: pylock-charmcraft.yaml + :language: yaml + + +.. _PEP 751: https://peps.python.org/pep-0751/ diff --git a/tests/conftest.py b/tests/conftest.py index 8e9b60ece..5ddd08826 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -466,6 +466,30 @@ def poetry_plugin(tmp_path: pathlib.Path): ) +@pytest.fixture +def pylock_plugin(tmp_path: pathlib.Path): + project_dirs = craft_parts.ProjectDirs(work_dir=tmp_path) + spec = { + "plugin": "pylock", + "source": str(tmp_path), + } + plugin_properties = parts.plugins.PylockPluginProperties.unmarshal(spec) + part_spec = craft_parts.plugins.extract_part_properties(spec, plugin_name="pylock") + 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=project_info, part=part) + + return craft_parts.plugins.get_plugin( + part=part, part_info=part_info, properties=plugin_properties + ) + + @pytest.fixture def python_plugin(tmp_path: pathlib.Path): project_dirs = craft_parts.ProjectDirs(work_dir=tmp_path) diff --git a/tests/integration/parts/plugins/test_pylock.py b/tests/integration/parts/plugins/test_pylock.py new file mode 100644 index 000000000..15ba8809e --- /dev/null +++ b/tests/integration/parts/plugins/test_pylock.py @@ -0,0 +1,79 @@ +# 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 + +import pathlib +import subprocess +import sys + +import craft_application +import pytest + +pytestmark = [ + pytest.mark.skipif(sys.platform != "linux", reason="craft-parts is linux-only") +] + + +@pytest.fixture(autouse=True) +def add_part( + service_factory: craft_application.ServiceFactory, project_path: pathlib.Path +): + service_factory.get("project").get().parts = { + "my-charm": { + "plugin": "pylock", + "source": str(project_path), + "source-type": "local", + } + } + + +@pytest.fixture +def pylock_project(project_path: pathlib.Path) -> None: + # ``pip lock`` (the PEP 751 lock file generator) is itself experimental and + # was added in pip 25.1; skip if this runner's pip can't produce one. + result = subprocess.run( + [sys.executable, "-m", "pip", "lock", "ops", "--output", "pylock.toml"], + cwd=project_path, + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.skip(f"could not generate a pylock.toml with this pip:\n{result.stderr}") + + source_dir = project_path / "src" + source_dir.mkdir() + (source_dir / "charm.py").write_text("# Charm file") + + +@pytest.mark.slow +@pytest.mark.usefixtures("pylock_project") +def test_pylock_plugin( + service_factory: craft_application.ServiceFactory, tmp_path: pathlib.Path +): + install_path = tmp_path / "parts" / "my-charm" / "install" + stage_path = tmp_path / "stage" + + 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() + # The locked dependency was installed into the venv. + assert next((install_path / "venv" / "lib").glob("python*/site-packages/ops")) + + # 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() diff --git a/tests/spread/ubuntu-26.04/charm/pylock/charmcraft.yaml b/tests/spread/ubuntu-26.04/charm/pylock/charmcraft.yaml new file mode 100644 index 000000000..dacc50fa3 --- /dev/null +++ b/tests/spread/ubuntu-26.04/charm/pylock/charmcraft.yaml @@ -0,0 +1,29 @@ +name: test-charm +type: charm +title: Charm Template +summary: A charm that installs its dependencies from a PEP 751 pylock.toml. +description: | + A single sentence that says what the charm is, concisely and memorably. + +base: ubuntu@26.04 +build-base: ubuntu@devel +platforms: + # The pyyaml wheel pinned in pylock.toml is amd64-specific, so this fixture + # is restricted to a single platform. + amd64: + +parts: + charm: + plugin: pylock + source: . + +config: + options: + # An example config option to customise the log level of the workload + log-level: + description: | + Configures the log level of gunicorn. + + Acceptable values are: "info", "debug", "warning", "error" and "critical" + default: "info" + type: string diff --git a/tests/spread/ubuntu-26.04/charm/pylock/expected_files.txt b/tests/spread/ubuntu-26.04/charm/pylock/expected_files.txt new file mode 100644 index 000000000..e636f739a --- /dev/null +++ b/tests/spread/ubuntu-26.04/charm/pylock/expected_files.txt @@ -0,0 +1,6 @@ +metadata.yaml +manifest.yaml +config.yaml +dispatch +src/charm.py +venv/lib/python3.14/site-packages/ops/charm.py diff --git a/tests/spread/ubuntu-26.04/charm/pylock/pylock.toml b/tests/spread/ubuntu-26.04/charm/pylock/pylock.toml new file mode 100644 index 000000000..90ac66424 --- /dev/null +++ b/tests/spread/ubuntu-26.04/charm/pylock/pylock.toml @@ -0,0 +1,68 @@ +lock-version = "1.0" +created-by = "pip" + +[[packages]] +name = "opentelemetry-api" +version = "1.42.0" + +[[packages.wheels]] +name = "opentelemetry_api-1.42.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/1b/0b/be5daf659b82b525338fde371dfcfab09b606a19bb5620c37076964710ec/opentelemetry_api-1.42.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "558d88f88192a973579910ef6f2c13db47a268d5ec2e53e83e50e74a39a02922" + +[[packages]] +name = "ops" +version = "3.7.0" + +[[packages.wheels]] +name = "ops-3.7.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/35/b0/19722b4b51696fbca41d3454f3dd3a73e89951303487b47280c6f3e277d4/ops-3.7.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "7050d5e629ac17de9d443e64f4ad09857e8012c9012c8ba66c9e765899d50bd1" + +[[packages]] +name = "overrides" +version = "7.7.0" + +[[packages.wheels]] +name = "overrides-7.7.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49" + +[[packages]] +name = "pyyaml" +version = "6.0.3" + +[[packages.wheels]] +name = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" + +[packages.wheels.hashes] +sha256 = "c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" + +[[packages]] +name = "typing-extensions" +version = "4.15.0" + +[[packages.wheels]] +name = "typing_extensions-4.15.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + +[[packages]] +name = "websocket-client" +version = "1.9.0" + +[[packages.wheels]] +name = "websocket_client-1.9.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" diff --git a/tests/spread/ubuntu-26.04/charm/pylock/src/charm.py b/tests/spread/ubuntu-26.04/charm/pylock/src/charm.py new file mode 100644 index 000000000..505f18b59 --- /dev/null +++ b/tests/spread/ubuntu-26.04/charm/pylock/src/charm.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm the application.""" + +import logging + +import ops + +logger = logging.getLogger(__name__) + + +class PylockCharm(ops.CharmBase): + """Charm the application.""" + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event: ops.StartEvent): + """Handle start event.""" + self.unit.status = ops.ActiveStatus() + + +if __name__ == "__main__": # pragma: nocover + ops.main(PylockCharm) # type: ignore diff --git a/tests/spread/ubuntu-26.04/charm/pylock/task.yaml b/tests/spread/ubuntu-26.04/charm/pylock/task.yaml new file mode 100644 index 000000000..11eff9e30 --- /dev/null +++ b/tests/spread/ubuntu-26.04/charm/pylock/task.yaml @@ -0,0 +1,9 @@ +summary: pack a charm with a PEP 751 pylock.toml + +restore: | + rm -rf ./*.charm + +execute: | + charmcraft pack 2>&1 + CHARM_OUTPUT=$(find . -type f -name "*.charm") + charmcraft analyse $CHARM_OUTPUT diff --git a/tests/unit/parts/plugins/test_pylock.py b/tests/unit/parts/plugins/test_pylock.py new file mode 100644 index 000000000..45ff4971c --- /dev/null +++ b/tests/unit/parts/plugins/test_pylock.py @@ -0,0 +1,142 @@ +# 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 +"""Unit tests for the Charmcraft-specific pylock plugin.""" + +import pathlib + +import pytest +import pytest_check + +from charmcraft.parts import plugins + + +def test_get_build_packages(pylock_plugin: plugins.PylockPlugin): + assert "python3-pip" in pylock_plugin.get_build_packages() + + +def test_get_build_environment_keeps_pip_and_binaries( + pylock_plugin: plugins.PylockPlugin, +): + env = pylock_plugin.get_build_environment() + + # The venv must keep pip (no --without-pip) so it can read pylock.toml, + # and PIP_NO_BINARY must not be set or recorded wheel hashes would fail. + pytest_check.not_equal(env.get("PARTS_PYTHON_VENV_ARGS"), "--without-pip") + pytest_check.is_not_in("PIP_NO_BINARY", env) + + +def test_get_venv_directory( + pylock_plugin: plugins.PylockPlugin, install_path: pathlib.Path +): + assert pylock_plugin._get_venv_directory() == install_path / "venv" + + +def test_get_package_install_commands( + pylock_plugin: plugins.PylockPlugin, + build_path: pathlib.Path, + install_path: pathlib.Path, +): + pylock_plugin._get_pip = lambda: "/python -m pip" + copy_src_cmd = ( + f"cp --archive --recursive --reflink=auto {build_path}/src {install_path}" + ) + copy_lib_cmd = ( + f"cp --archive --recursive --reflink=auto {build_path}/lib {install_path}" + ) + + commands = pylock_plugin._get_package_install_commands() + + # pip is bootstrapped to a version that understands pylock.toml, then the + # lock file is installed and the environment is checked. + pytest_check.is_in("/python -m pip install --upgrade 'pip>=26.1'", commands) + pytest_check.is_in("/python -m pip install --requirement=pylock.toml", commands) + pytest_check.is_in("/python -m pip check", commands) + pytest_check.is_not_in(copy_src_cmd, commands) + pytest_check.is_not_in(copy_lib_cmd, commands) + + (build_path / "src").mkdir() + + pytest_check.is_in(copy_src_cmd, pylock_plugin._get_package_install_commands()) + pytest_check.is_not_in(copy_lib_cmd, pylock_plugin._get_package_install_commands()) + + (build_path / "lib").mkdir() + + pytest_check.is_in(copy_src_cmd, pylock_plugin._get_package_install_commands()) + pytest_check.is_in(copy_lib_cmd, pylock_plugin._get_package_install_commands()) + + (build_path / "src").rmdir() + + pytest_check.is_not_in(copy_src_cmd, pylock_plugin._get_package_install_commands()) + pytest_check.is_in(copy_lib_cmd, pylock_plugin._get_package_install_commands()) + + +def test_install_commands_quote_pylock_file(pylock_plugin: plugins.PylockPlugin): + spec = { + "plugin": "pylock", + "source": ".", + "pylock-file": "pylock.dev.toml", + } + pylock_plugin._options = plugins.PylockPluginProperties.unmarshal(spec) + pylock_plugin._get_pip = lambda: "pip" + + assert "pip install --requirement=pylock.dev.toml" in ( + pylock_plugin._get_package_install_commands() + ) + + +@pytest.mark.parametrize( + "pylock_file", + ["pylock.toml", "pylock.dev.toml", "pylock.foo-bar.toml"], +) +def test_valid_pylock_file_names(pylock_file: str): + spec = {"plugin": "pylock", "source": ".", "pylock-file": pylock_file} + + assert plugins.PylockPluginProperties.unmarshal(spec).pylock_file == pylock_file + + +@pytest.mark.parametrize( + "pylock_file", + ["requirements.txt", "lock.toml", "mylock.toml", "pylock.json", "pylock"], +) +def test_invalid_pylock_file_names(pylock_file: str): + spec = {"plugin": "pylock", "source": ".", "pylock-file": pylock_file} + + with pytest.raises(ValueError, match="not a valid PEP 751 lock file name"): + plugins.PylockPluginProperties.unmarshal(spec) + + +def test_get_rm_command( + pylock_plugin: plugins.PylockPlugin, install_path: pathlib.Path +): + assert ( + f"rm -rf {install_path / 'venv/bin'}/!(activate)" + in pylock_plugin.get_build_commands() + ) + + +def test_no_get_rm_command( + pylock_plugin: plugins.PylockPlugin, install_path: pathlib.Path +): + spec = { + "plugin": "pylock", + "source": ".", + "pylock-keep-bins": True, + } + pylock_plugin._options = plugins.PylockPluginProperties.unmarshal(spec) + assert ( + f"rm -rf {install_path / 'venv/bin'}/!(activate)" + not in pylock_plugin.get_build_commands() + )