diff --git a/docs/reference/ops-testing.rst b/docs/reference/ops-testing.rst index b4cb01a44..a23b13c77 100644 --- a/docs/reference/ops-testing.rst +++ b/docs/reference/ops-testing.rst @@ -101,7 +101,7 @@ This API for testing was previously called 'Scenario'. :special-members: __call__ .. autoclass:: ops.testing.DeferredEvent .. autoclass:: ops.testing.ErrorStatus -.. autoclass:: ops.testing.Event() +.. autoclass:: ops.testing.Event .. autoclass:: ops.testing.Exec .. autoclass:: ops.testing.ICMPPort .. autoclass:: ops.testing.JujuLogLine diff --git a/testing/src/scenario/_charm_requirements.py b/testing/src/scenario/_charm_requirements.py new file mode 100644 index 000000000..4d219d7cd --- /dev/null +++ b/testing/src/scenario/_charm_requirements.py @@ -0,0 +1,368 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Install a charm's Python dependencies the way its build plugin would. + +This follows what ``charmcraft pack`` does, so that a charm can be run in a +virtual environment with its own dependencies rather than the test's. Rather +than reading requirements files itself, it gives pip and uv the same +arguments that charmcraft does, from the same directory: the part's +``source`` and ``source-subdir``. Two plugins are supported: + +* ``python``: ``pip install --no-deps`` with the part's + ``python-constraints``, ``python-requirements`` and ``python-packages``. As + with charmcraft's python plugin, the ``PYDEPS`` of the charm's libraries + aren't included. +* ``uv``: what ``uv sync`` would install from the charm's ``uv.lock``, with + the same options as charmcraft's uv plugin and the part's ``uv-extras`` + and ``uv-groups``. Unlike charmcraft, which uses the lockfile as it is, a + ``uv.lock`` that's out of date with ``pyproject.toml`` is an error, so that + the charm isn't tested with dependencies other than the ones it asks for. + +The charm's own ``ops``, ``ops-scenario`` and ``ops-tracing`` are never left +installed: the charm uses the test's. Whatever runs the charm in this +environment has to make the test's own ``ops`` importable. This module only +makes sure the charm's copies can't shadow it. + +Charms that use an extension, such as ``flask-framework``, aren't supported: +the extension adds parts that aren't in ``charmcraft.yaml``. Nor are charms +with more than one part that installs Python dependencies. +""" + +from __future__ import annotations + +import dataclasses +import pathlib +import shlex +import shutil +import subprocess +import sys +from collections.abc import Mapping, Sequence +from typing import Any, cast + +import yaml + +#: The build plugins that dependencies can be found for. +_SUPPORTED_PLUGINS = ('python', 'uv') +#: Plugins that are charmcraft's, but that dependencies can't be found for. +_UNSUPPORTED_PLUGINS = ('poetry', 'reactive') +#: Plugins that don't install Python dependencies, so are skipped when looking +#: for the part that does: craft-parts' built-in plugins, apart from the Python +#: ones above. A part using any other plugin might install Python dependencies, +#: so the charm is refused rather than given an environment that could be +#: missing some. +_OTHER_PLUGINS = ( + 'ant', 'autotools', 'bazel', 'cargo-use', 'cmake', 'dotnet', 'dump', 'go', 'go-use', + 'gradle', 'gradle-use', 'jlink', 'make', 'maven', 'maven-use', 'meson', 'nil', 'npm', + 'npm-use', 'qmake', 'ruby', 'rust', 'scons', +) # fmt: skip + +#: The charm's environment never has these installed: the test's own are used. +#: ops-tracing pins an exact ops version, so it has to match the test's ops too. +_OPS_DISTRIBUTIONS = ('ops', 'ops-scenario', 'ops-tracing') + + +class _RequirementsError(Exception): + """The charm's dependencies can't be found or installed.""" + + +@dataclasses.dataclass(frozen=True) +class _Requirements: + """What to install for a charm, as arguments for ``pip install --no-deps``.""" + + directory: pathlib.Path + """The part's build directory. Relative paths in ``args`` are relative to it.""" + + args: tuple[str, ...] + """Requirements and options, as separate command-line arguments.""" + + check: bool = False + """Whether to run ``pip check`` after installing, as charmcraft's python plugin does. + + The uv plugin's export leaves out ``ops``, so a locked package that + depends on it would fail the check. + """ + + +def install_charm_requirements(charm_root: pathlib.Path, python: pathlib.Path) -> None: + """Install the charm's dependencies into the environment that ``python`` runs in. + + The list of dependencies is complete, as it is for charmcraft, so they + are installed without resolving dependencies. The charm's own ``ops``, + ``ops-scenario`` and ``ops-tracing`` are then removed. The uv plugin's + export already leaves them out, but removing them whatever the plugin + keeps that true without relying on it. + + Raises: + _RequirementsError: if the charm's dependencies can't be found (see + :func:`charm_requirements`), installing them fails, or ``python`` + is the interpreter running the tests, whose ``ops`` would be + removed. + """ + if _prefix(python) == pathlib.Path(sys.prefix).resolve(): + raise _RequirementsError( + "Refusing to install a charm's dependencies into the test's own environment." + ) + requirements = charm_requirements(charm_root) + if requirements.args: + install = _pip(python, 'install', '--no-deps', *requirements.args) + _run(install, cwd=requirements.directory) + # Before ops is removed, which deliberately leaves dependencies missing. + if requirements.check: + _run(_pip(python, 'check')) + _run(_pip(python, 'uninstall', *_OPS_DISTRIBUTIONS)) + + +def _prefix(python: pathlib.Path) -> pathlib.Path: + """The environment that ``python`` runs in. + + A virtual environment's interpreter is usually a symlink to the base one, + so the interpreter's own path doesn't say which environment it is. + """ + cmd = [str(python), '-c', 'import sys; print(sys.prefix)'] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise _RequirementsError(f'`{shlex.join(cmd)}` failed:\n{result.stderr.strip()}\n') + return pathlib.Path(result.stdout.strip()).resolve() + + +def _pip(python: pathlib.Path, command: str, *args: str) -> list[str]: + """A pip command for the environment that ``python`` runs in. + + uv is used if it's on PATH, because an environment that uv made has no + pip. Otherwise, the test's own pip is used, as charmcraft does. + """ + if uv := shutil.which('uv'): + return [uv, 'pip', command, '--quiet', '--python', str(python), *args] + # pip only accepts --python before the command. + pip = [sys.executable, '-m', 'pip', '--python', str(python), command, '--quiet'] + return [*pip, '--yes', *args] if command == 'uninstall' else [*pip, *args] + + +def _run(cmd: Sequence[str], cwd: pathlib.Path | None = None) -> None: + result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) + if result.returncode != 0: + # pip writes some failures, such as pip check's, to stdout rather than stderr. + output = '\n'.join(o.strip() for o in (result.stderr, result.stdout) if o.strip()) + raise _RequirementsError(f'`{shlex.join(cmd)}` failed:\n{output}\n') + + +def charm_requirements(charm_root: pathlib.Path) -> _Requirements: + """What the charm's build plugin would install. + + Raises: + _RequirementsError: if the build plugin can't be found or isn't + supported, a file the plugin names is missing, or ``uv export`` + fails. + """ + plugin, part = detect_plugin(charm_root) + directory = _build_directory(charm_root, part) + if plugin == 'uv': + return _Requirements(directory, _uv_requirements(directory, part)) + return _Requirements(directory, _python_requirements(directory, part), check=True) + + +def detect_plugin(charm_root: pathlib.Path) -> tuple[str, Mapping[str, Any]]: + """Find the charm's build plugin, and the ``charmcraft.yaml`` part that uses it. + + Raises: + _RequirementsError: if there's no ``charmcraft.yaml`` or it can't be + read, or the plugin isn't supported. That includes the deprecated + ``charm`` plugin, which charmcraft uses when ``charmcraft.yaml`` + has no ``parts``, charms that use an extension, and charms with + more than one part that installs Python dependencies. + """ + path = charm_root / 'charmcraft.yaml' + try: + charmcraft: Any = yaml.safe_load(path.read_text()) or {} + except FileNotFoundError: + raise _RequirementsError( + f'{charm_root} has no charmcraft.yaml, so its build plugin is unknown.' + ) from None + except (OSError, yaml.YAMLError) as e: + raise _RequirementsError(f'Could not read {path}: {e}') from None + if not isinstance(charmcraft, dict): + raise _RequirementsError(f'{path} is not a mapping.') + charmcraft = cast('dict[str, Any]', charmcraft) + if extensions := charmcraft.get('extensions'): + # The extension adds or changes the charm part, and its plugin + # depends on the base. + raise _RequirementsError( + f'{path} uses the {", ".join(map(str, extensions))} extension, which adds ' + "parts that aren't in charmcraft.yaml, so finding the charm's dependencies " + "isn't supported." + ) + parts: Any = charmcraft.get('parts') + if not parts: + raise _charm_plugin(f'{path} has no parts') + if not isinstance(parts, dict): + raise _RequirementsError(f'parts in {path} is not a mapping.') + return _python_part(path, cast('dict[str, Any]', parts)) + + +def _python_part(path: pathlib.Path, parts: dict[str, Any]) -> tuple[str, Mapping[str, Any]]: + """The one part that installs Python dependencies, and its plugin. + + Every part is checked, because charmcraft builds them all. + """ + found: list[tuple[str, str, Mapping[str, Any]]] = [] + unknown: list[str] = [] + for name, part in parts.items(): + # A part with no keys at all is null in YAML. + part = cast('dict[str, Any]', {} if part is None else part) + if not isinstance(part, dict): + raise _RequirementsError(f'Part {name!r} in {path} is not a mapping.') + plugin = part.get('plugin', name) + if plugin in _SUPPORTED_PLUGINS: + found.append((name, plugin, part)) + elif plugin == 'charm': + raise _charm_plugin(f'Part {name!r} in {path} uses the charm plugin') + elif plugin in _UNSUPPORTED_PLUGINS: + raise _RequirementsError( + f'Part {name!r} in {path} uses the {plugin} plugin: {_unsupported(plugin)}' + ) + elif plugin not in _OTHER_PLUGINS: + unknown.append(f'{name} ({plugin})') + if unknown: + raise _RequirementsError( + f"Can't find the dependencies of the charm in {path}, because these parts use " + f'plugins that might install Python dependencies too: {", ".join(unknown)}.' + ) + if len(found) > 1: + names = ', '.join(f'{name} ({plugin})' for name, plugin, _ in found) + raise _RequirementsError( + f'More than one part in {path} installs Python dependencies: {names}. ' + "Finding the dependencies of a charm with more than one isn't supported." + ) + if found: + _, plugin, part = found[0] + return plugin, part + raise _RequirementsError(f'No part in {path} uses the python or uv plugin.') + + +def _unsupported(plugin: str) -> str: + return f"finding the dependencies of a charm built with the {plugin} plugin isn't supported." + + +def _charm_plugin(reason: str) -> _RequirementsError: + return _RequirementsError( + f"{reason}, so it's built with the charm plugin, which is deprecated and " + "isn't supported. Move the charm to the uv or python plugin." + ) + + +def _build_directory(charm_root: pathlib.Path, part: Mapping[str, Any]) -> pathlib.Path: + """The directory charmcraft builds the part in: its ``source`` and ``source-subdir``. + + A ``source`` may lead out of the charm, up to the root of the git repository + it's in: charmcraft's experimental monorepo mode mounts the whole repository, + and its documentation has charms use ``source: ../..`` with a + ``source-subdir`` back to the charm. Nothing outside the repository is ever + available to charmcraft, so that's refused. + """ + source = str(part.get('source', '.')) + if '://' in source or source.startswith('git@'): + raise _RequirementsError( + f"The part's source, {source}, isn't a local directory, so finding the " + "charm's dependencies isn't supported." + ) + directory = (charm_root / source / str(part.get('source-subdir', ''))).resolve() + if not directory.is_relative_to(_repository_root(charm_root.resolve())): + raise _RequirementsError( + f"The part's source is {directory}, which is outside the charm's git repository, " + "so charmcraft can't build it." + ) + if not directory.is_dir(): + raise _RequirementsError( + f"The part's source is {directory}, which isn't a directory, so finding the " + "charm's dependencies isn't supported." + ) + return directory + + +def _repository_root(charm_root: pathlib.Path) -> pathlib.Path: + """The root of the git repository the charm is in, or the charm itself if it isn't in one.""" + for directory in (charm_root, *charm_root.parents): + if (directory / '.git').exists(): + return directory + return charm_root + + +def _python_requirements(directory: pathlib.Path, part: Mapping[str, Any]) -> tuple[str, ...]: + """The arguments charmcraft's ``python`` plugin passes to ``pip install --no-deps``.""" + constraints = _list(part, 'python-constraints') + requirements = _list(part, 'python-requirements') + packages = _list(part, 'python-packages') + for name, kind in [(c, 'constraints') for c in constraints] + [ + (r, 'requirements') for r in requirements + ]: + if not (directory / name).is_file(): + raise _RequirementsError( + f'The python plugin lists {name} as a {kind} file, but there is no ' + f'{directory / name}.' + ) + return ( + *(f'--constraint={name}' for name in constraints), + *(f'--requirement={name}' for name in requirements), + *packages, + ) + + +def _list(part: Mapping[str, Any], key: str) -> list[str]: + """A list option of the part, which is empty if it isn't set.""" + value = part.get(key, []) + if not isinstance(value, list): + raise _RequirementsError(f'{key} in charmcraft.yaml is not a list.') + return [str(item) for item in cast('list[Any]', value)] + + +def _uv_requirements(directory: pathlib.Path, part: Mapping[str, Any]) -> tuple[str, ...]: + """The requirements ``uv export`` gives from the charm's lockfile.""" + extras = _list(part, 'uv-extras') + groups = _list(part, 'uv-groups') + if not (directory / 'uv.lock').exists(): + raise _RequirementsError( + f'{directory} is built with the uv plugin but has no uv.lock. Run `uv lock` in it.' + ) + uv = shutil.which('uv') + if uv is None: + raise _RequirementsError( + "uv is not on PATH, and it's needed to export the requirements from the " + "charm's uv.lock. Install uv (https://docs.astral.sh/uv/)." + ) + cmd = [ + uv, + 'export', + # charmcraft uses the lockfile as it is (UV_FROZEN), but testing with a stale + # lockfile would test the charm with dependencies it no longer asks for. + '--locked', + '--no-hashes', + '--no-header', + '--no-annotate', + '--format', + 'requirements-txt', + # The options charmcraft's uv plugin passes to `uv sync`. + '--no-dev', + '--no-editable', + # charmcraft passes `--no-install-project`: the charm's code is copied, not installed. + '--no-emit-project', + *(arg for name in _OPS_DISTRIBUTIONS for arg in ('--no-emit-package', name)), + *(arg for extra in extras for arg in ('--extra', extra)), + *(arg for group in groups for arg in ('--group', group)), + ] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=directory) + if result.returncode != 0: + output = result.stderr.strip() + if '--locked' in output or 'needs to be updated' in output: + raise _RequirementsError( + f'The uv.lock in {directory} is out of date with its pyproject.toml. ' + 'Run `uv lock` in it.' + ) + raise _RequirementsError(f'`uv export` failed in {directory}:\n{output}\n') + # Each line is one requirement, with any path relative to the directory. + return tuple( + arg + for line in result.stdout.splitlines() + if line.strip() and not line.lstrip().startswith('#') + for arg in (shlex.split(line) if line.startswith('-') else [line.strip()]) + ) diff --git a/testing/tests/test_charm_requirements.py b/testing/tests/test_charm_requirements.py new file mode 100644 index 000000000..29af96dcd --- /dev/null +++ b/testing/tests/test_charm_requirements.py @@ -0,0 +1,514 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +from __future__ import annotations + +import pathlib +import shutil +import subprocess +import sys +import textwrap +import zipfile + +import pytest +from scenario import _charm_requirements +from scenario._charm_requirements import _RequirementsError + + +def _charmcraft(root: pathlib.Path, text: str) -> pathlib.Path: + root.mkdir(parents=True, exist_ok=True) + (root / 'charmcraft.yaml').write_text(textwrap.dedent(text)) + return root + + +@pytest.mark.parametrize('plugin', ['python', 'uv']) +def test_detects_the_plugin_from_charmcraft_yaml(tmp_path: pathlib.Path, plugin: str): + root = _charmcraft( + tmp_path, + f""" + type: charm + parts: + deps: + plugin: nil + charm: + plugin: {plugin} + source: . + {plugin}-extra: x + """, + ) + detected, part = _charm_requirements.detect_plugin(root) + assert detected == plugin + assert part[f'{plugin}-extra'] == 'x' + + +CHARM_PLUGIN_ERROR = ( + r'charm plugin, which is deprecated and isn\'t supported\. ' + r'Move the charm to the uv or python plugin\.' +) + + +def test_the_charm_plugin_is_an_error(tmp_path: pathlib.Path): + root = _charmcraft(tmp_path, 'parts:\n deps:\n plugin: nil\n build:\n plugin: charm\n') + with pytest.raises(_RequirementsError, match=rf"Part 'build'.*{CHARM_PLUGIN_ERROR}"): + _charm_requirements.detect_plugin(root) + + +def test_a_part_named_charm_with_no_plugin_is_the_charm_plugin(tmp_path: pathlib.Path): + root = _charmcraft(tmp_path, 'parts:\n charm:\n charm-requirements: [reqs.txt]\n') + with pytest.raises(_RequirementsError, match=rf"Part 'charm'.*{CHARM_PLUGIN_ERROR}"): + _charm_requirements.detect_plugin(root) + + +def test_charmcraft_yaml_with_no_parts_is_the_charm_plugin(tmp_path: pathlib.Path): + root = _charmcraft(tmp_path, 'type: charm\nname: x\n') + (root / 'uv.lock').write_text('') + with pytest.raises(_RequirementsError, match=rf'has no parts.*{CHARM_PLUGIN_ERROR}'): + _charm_requirements.detect_plugin(root) + + +def test_no_charmcraft_yaml_is_an_error(tmp_path: pathlib.Path): + # A lockfile isn't enough: charmcraft can't build a charm without charmcraft.yaml. + (tmp_path / 'uv.lock').write_text('') + with pytest.raises(_RequirementsError, match=r'has no charmcraft.yaml'): + _charm_requirements.detect_plugin(tmp_path) + + +@pytest.mark.parametrize('parts', ['', 'parts:\n charm:\n build-snaps: [rustup]\n']) +def test_an_extension_is_an_error(tmp_path: pathlib.Path, parts: str): + # The extension's part sets the plugin, which depends on the base. + root = _charmcraft(tmp_path, f'base: ubuntu@26.04\nextensions: [flask-framework]\n{parts}') + with pytest.raises(_RequirementsError, match=r'uses the flask-framework extension'): + _charm_requirements.detect_plugin(root) + + +@pytest.mark.parametrize('name', ['charm', 'deps']) +def test_an_extension_with_a_uv_part_is_an_error(tmp_path: pathlib.Path, name: str): + # charmcraft rejects a customised charm part, and adds its own charm part + # alongside a part with another name. + root = _charmcraft( + tmp_path, f'extensions: [flask-framework]\nparts:\n {name}:\n plugin: uv\n' + ) + with pytest.raises(_RequirementsError, match=r'uses the flask-framework extension'): + _charm_requirements.detect_plugin(root) + + +@pytest.mark.parametrize('order', [('python', 'charm'), ('charm', 'python')]) +def test_every_part_is_checked(tmp_path: pathlib.Path, order: tuple[str, str]): + parts = ''.join(f' {plugin}-part:\n plugin: {plugin}\n' for plugin in order) + root = _charmcraft(tmp_path, f'parts:\n{parts}') + with pytest.raises(_RequirementsError, match=CHARM_PLUGIN_ERROR): + _charm_requirements.detect_plugin(root) + + +def test_more_than_one_python_part_is_an_error(tmp_path: pathlib.Path): + root = _charmcraft( + tmp_path, 'parts:\n charm:\n plugin: uv\n extra:\n plugin: python\n' + ) + with pytest.raises(_RequirementsError, match=r'More than one part .*charm \(uv\), extra'): + _charm_requirements.detect_plugin(root) + + +@pytest.mark.parametrize('plugin', ['poetry', 'reactive']) +def test_unsupported_plugins_are_an_error(tmp_path: pathlib.Path, plugin: str): + root = _charmcraft(tmp_path, f'parts:\n charm:\n plugin: {plugin}\n') + with pytest.raises(_RequirementsError, match=rf"the {plugin} plugin isn't supported"): + _charm_requirements.detect_plugin(root) + + +@pytest.mark.parametrize('python_plugin', ['', ' deps:\n plugin: uv\n']) +def test_unknown_plugin_is_refused(tmp_path: pathlib.Path, python_plugin: str): + # Even next to a Python part: the unknown one might install dependencies too. + root = _charmcraft( + tmp_path, + f'parts:\n{python_plugin} web:\n plugin: flask-framework\n' + ' files:\n plugin: dump\n', + ) + with pytest.raises(_RequirementsError, match=r'too: web \(flask-framework\)\.$'): + _charm_requirements.detect_plugin(root) + + +def test_other_craft_parts_plugins_are_skipped(tmp_path: pathlib.Path): + root = _charmcraft(tmp_path, 'parts:\n deps:\n plugin: uv\n java:\n plugin: maven\n') + plugin, _ = _charm_requirements.detect_plugin(root) + assert plugin == 'uv' + + +@pytest.mark.parametrize( + ('text', 'error'), + [ + ('parts: [\n', 'Could not read'), + ('[parts]\n', 'charmcraft.yaml is not a mapping'), + ('parts: [a, b]\n', 'parts in .* is not a mapping'), + ('parts:\n charm: x\n', "Part 'charm' in .* is not a mapping"), + ], +) +def test_a_malformed_charmcraft_yaml_is_an_error(tmp_path: pathlib.Path, text: str, error: str): + root = _charmcraft(tmp_path, text) + with pytest.raises(_RequirementsError, match=error): + _charm_requirements.detect_plugin(root) + + +def test_python_plugin_requirements(tmp_path: pathlib.Path): + root = _charmcraft( + tmp_path, + """ + parts: + charm: + plugin: python + source: . + source-subdir: src dir + python-requirements: [requirements.txt] + python-packages: [extra==1, ./vendor/lib] + python-constraints: [constraints.txt] + """, + ) + (root / 'src dir').mkdir() + (root / 'src dir' / 'requirements.txt').write_text('requests==2.32\n') + (root / 'src dir' / 'constraints.txt').write_text('urllib3<3\n') + # charmcraft's python plugin doesn't install charm libraries' PYDEPS. + (root / 'lib' / 'charms' / 'thing' / 'v0').mkdir(parents=True) + (root / 'lib' / 'charms' / 'thing' / 'v0' / 'thing.py').write_text( + 'PYDEPS = ["pydantic>=2"]\n' + ) + # The files and packages are given to pip as charmcraft gives them, to run + # in the part's build directory. + assert _charm_requirements.charm_requirements(root) == _charm_requirements._Requirements( + root / 'src dir', + ( + '--constraint=constraints.txt', + '--requirement=requirements.txt', + 'extra==1', + './vendor/lib', + ), + check=True, + ) + + +def test_a_part_source_that_is_not_local_is_an_error(tmp_path: pathlib.Path): + root = _charmcraft( + tmp_path, 'parts:\n charm:\n plugin: python\n source: https://x.com/c.git\n' + ) + with pytest.raises(_RequirementsError, match=r"isn't a local directory"): + _charm_requirements.charm_requirements(root) + + +@pytest.mark.parametrize('kind', ['requirements', 'constraints']) +def test_a_missing_listed_file_is_an_error(tmp_path: pathlib.Path, kind: str): + root = _charmcraft( + tmp_path, f'parts:\n charm:\n plugin: python\n python-{kind}: [x.txt]\n' + ) + with pytest.raises(_RequirementsError, match=rf'lists x.txt as a {kind} file'): + _charm_requirements.charm_requirements(root) + + +@pytest.mark.parametrize( + 'key', + ['python-requirements', 'python-packages', 'python-constraints', 'uv-extras', 'uv-groups'], +) +def test_a_list_option_that_is_not_a_list_is_an_error( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, key: str +): + monkeypatch.setenv('PATH', str(tmp_path / 'empty')) + plugin = key.partition('-')[0] + root = _charmcraft(tmp_path, f'parts:\n charm:\n plugin: {plugin}\n {key}: x.txt\n') + (root / 'uv.lock').write_text('') + with pytest.raises(_RequirementsError, match=rf'{key} in .* is not a list'): + _charm_requirements.charm_requirements(root) + + +def _uv_charm(root: pathlib.Path, part: str = '', pyproject: str = '') -> pathlib.Path: + """A uv charm with path dependencies, so that locking it needs no index.""" + for name in ('base', 'extra', 'grouped', 'dev', 'editable', 'ops', 'ops-tracing'): + (root / 'deps' / name).mkdir(parents=True) + (root / 'deps' / name / 'pyproject.toml').write_text( + f'[project]\nname = "{name}"\nversion = "1.0"\n' + ) + _charmcraft(root, f'parts:\n charm:\n plugin: uv\n source: .\n{part}') + (root / 'pyproject.toml').write_text( + textwrap.dedent(""" + [project] + name = "charm" + version = "0" + requires-python = ">=3.10" + dependencies = ["base", "editable", "ops", "ops-tracing"] + + [project.optional-dependencies] + x = ["extra"] + + [dependency-groups] + dev = ["dev"] + g = ["grouped"] + + [tool.uv.sources] + base = { path = "deps/base" } + extra = { path = "deps/extra" } + grouped = { path = "deps/grouped" } + dev = { path = "deps/dev" } + editable = { path = "deps/editable", editable = true } + ops = { path = "deps/ops" } + ops-tracing = { path = "deps/ops-tracing" } + + # A charm that's a package, so that leaving the project out is tested. + [build-system] + requires = ["uv_build"] + build-backend = "uv_build" + """) + + pyproject + ) + subprocess.run( + [shutil.which('uv') or 'uv', 'lock', '--quiet', '--offline'], cwd=root, check=True + ) + return root + + +def _uv_args(root: pathlib.Path) -> list[str]: + requirements = _charm_requirements.charm_requirements(root) + assert requirements.directory == root + return sorted(requirements.args) + + +def test_uv_plugin_leaves_out_the_dev_group(tmp_path: pathlib.Path): + root = _uv_charm(tmp_path) + # Editable dependencies aren't editable, and the charm itself, ops and + # ops-tracing aren't included. + assert _uv_args(root) == ['./deps/base', './deps/editable'] + + +def test_uv_plugin_keeps_other_default_groups(tmp_path: pathlib.Path): + root = _uv_charm(tmp_path, pyproject='\n[tool.uv]\ndefault-groups = ["dev", "g"]\n') + assert _uv_args(root) == ['./deps/base', './deps/editable', './deps/grouped'] + + +def test_uv_plugin_forwards_extras_and_groups(tmp_path: pathlib.Path): + root = _uv_charm(tmp_path, ' uv-extras: [x]\n uv-groups: [g]\n') + assert _uv_args(root) == [ + './deps/base', + './deps/editable', + './deps/extra', + './deps/grouped', + ] + + +def test_uv_plugin_uses_the_source_subdir(tmp_path: pathlib.Path): + _uv_charm(tmp_path / 'charm') + root = _charmcraft( + tmp_path, 'parts:\n charm:\n plugin: uv\n source: .\n source-subdir: charm\n' + ) + requirements = _charm_requirements.charm_requirements(root) + assert requirements.directory == root / 'charm' + assert sorted(requirements.args) == ['./deps/base', './deps/editable'] + + +def test_uv_plugin_in_a_monorepo(tmp_path: pathlib.Path): + # The layout charmcraft's experimental monorepo mode documents: the part's + # source is the repository root, and the charm uses shared code from it. + repo = tmp_path / 'repo' + (repo / '.git').mkdir(parents=True) + (repo / 'shared' / 'common').mkdir(parents=True) + (repo / 'shared' / 'common' / 'pyproject.toml').write_text( + '[project]\nname = "common"\nversion = "1.0"\n' + ) + root = repo / 'charms' / 'my-charm' + _charmcraft( + root, + 'parts:\n my-charm:\n plugin: uv\n source: ../..\n' + ' source-subdir: charms/my-charm\n', + ) + (root / 'pyproject.toml').write_text( + textwrap.dedent(""" + [project] + name = "my-charm" + version = "0" + requires-python = ">=3.10" + dependencies = ["common"] + + [tool.uv.sources] + common = { path = "../../shared/common" } + """) + ) + subprocess.run( + [shutil.which('uv') or 'uv', 'lock', '--quiet', '--offline'], cwd=root, check=True + ) + requirements = _charm_requirements.charm_requirements(root) + assert requirements.directory == root.resolve() + assert requirements.args == ('../../shared/common',) + + +def test_a_part_source_outside_the_repository_is_an_error(tmp_path: pathlib.Path): + (tmp_path / 'repo' / '.git').mkdir(parents=True) + (tmp_path / 'elsewhere').mkdir() + root = _charmcraft( + tmp_path / 'repo' / 'charm', + 'parts:\n charm:\n plugin: python\n source: ../../elsewhere\n', + ) + with pytest.raises(_RequirementsError, match="outside the charm's git repository"): + _charm_requirements.charm_requirements(root) + + +def test_uv_plugin_with_a_stale_lockfile(tmp_path: pathlib.Path): + root = _uv_charm(tmp_path) + pyproject = root / 'pyproject.toml' + pyproject.write_text( + pyproject.read_text().replace('"ops-tracing"]', '"ops-tracing", "extra"]') + ) + with pytest.raises(_RequirementsError, match=r'uv.lock .* is out of date.*`uv lock`'): + _charm_requirements.charm_requirements(root) + + +def test_uv_plugin_without_a_lockfile(tmp_path: pathlib.Path): + root = _charmcraft(tmp_path, 'parts:\n charm:\n plugin: uv\n') + with pytest.raises(_RequirementsError, match=r'has no uv.lock\. Run `uv lock`'): + _charm_requirements.charm_requirements(root) + + +def test_uv_plugin_without_uv(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + root = _charmcraft(tmp_path, 'parts:\n charm:\n plugin: uv\n') + (root / 'uv.lock').write_text('') + monkeypatch.setenv('PATH', str(tmp_path / 'empty')) + with pytest.raises(_RequirementsError, match='uv is not on PATH'): + _charm_requirements.charm_requirements(root) + + +def _wheel(directory: pathlib.Path, name: str, requires: str = '') -> str: + """Build a wheel with no code, so that installing it needs no index or build.""" + directory.mkdir(parents=True, exist_ok=True) + stem = f'{name.replace("-", "_")}-1.0' + metadata = f'Metadata-Version: 2.1\nName: {name}\nVersion: 1.0\n' + if requires: + metadata += f'Requires-Dist: {requires}\n' + with zipfile.ZipFile(directory / f'{stem}-py3-none-any.whl', 'w') as wheel: + wheel.writestr(f'{stem}.dist-info/METADATA', metadata) + wheel.writestr( + f'{stem}.dist-info/WHEEL', + 'Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n', + ) + wheel.writestr(f'{stem}.dist-info/RECORD', '') + return f'{stem}-py3-none-any.whl' + + +def _installed(python: pathlib.Path) -> list[str]: + script = 'import importlib.metadata as m; print(*sorted(d.name for d in m.distributions()))' + result = subprocess.run( + [python, '-I', '-c', script], capture_output=True, text=True, check=True + ) + return result.stdout.split() + + +@pytest.fixture +def venv(tmp_path: pathlib.Path) -> pathlib.Path: + subprocess.run([sys.executable, '-m', 'venv', '--without-pip', tmp_path / 'venv'], check=True) + return tmp_path / 'venv' / 'bin' / 'python' + + +def test_install_python_plugin(tmp_path: pathlib.Path, venv: pathlib.Path): + # A space in the path, and relative paths in the part and in the files, + # work because pip runs in the part's build directory. + root = tmp_path / 'my charm' + wheels = root / 'src' / 'wheels' + names = {name: _wheel(wheels, name) for name in ('listed', 'packaged', 'ops', 'ops-scenario')} + _charmcraft( + root, + f""" + parts: + charm: + plugin: python + source: src + python-requirements: [requirements.txt] + python-packages: [./wheels/{names['packaged']}, ./wheels/{names['ops-scenario']}] + """, + ) + (root / 'src' / 'requirements.txt').write_text(f'./wheels/{names["listed"]}\n-r more.txt\n') + (root / 'src' / 'more.txt').write_text(f'./wheels/{names["ops"]}\n') + _charm_requirements.install_charm_requirements(root, venv) + assert _installed(venv) == ['listed', 'packaged'] + + +def test_install_uv_plugin(tmp_path: pathlib.Path, venv: pathlib.Path): + root = _charmcraft(tmp_path / 'charm', 'parts:\n charm:\n plugin: uv\n source: .\n') + names = {name: _wheel(root / 'wheels', name) for name in ('dep', 'ops')} + (root / 'pyproject.toml').write_text( + textwrap.dedent(f""" + [project] + name = "charm" + version = "0" + requires-python = ">=3.10" + dependencies = ["dep", "ops"] + + [tool.uv.sources] + dep = {{ path = "wheels/{names['dep']}" }} + ops = {{ path = "wheels/{names['ops']}" }} + """) + ) + subprocess.run( + [shutil.which('uv') or 'uv', 'lock', '--quiet', '--offline'], cwd=root, check=True + ) + _charm_requirements.install_charm_requirements(root, venv) + assert _installed(venv) == ['dep'] + + +def test_a_failed_install_is_an_error(tmp_path: pathlib.Path, venv: pathlib.Path): + root = _charmcraft( + tmp_path, 'parts:\n charm:\n plugin: python\n python-packages: [./missing]\n' + ) + with pytest.raises(_RequirementsError, match=r'--no-deps ./missing` failed'): + _charm_requirements.install_charm_requirements(root, venv) + + +def _no_uv(name: str) -> str | None: + return None + + +def test_a_failed_command_shows_its_stdout_too(): + script = 'import sys; print("on stdout"); print("on stderr", file=sys.stderr); sys.exit(1)' + with pytest.raises(_RequirementsError) as excinfo: + _charm_requirements._run([sys.executable, '-c', script]) + assert str(excinfo.value).endswith('failed:\non stderr\non stdout\n') + + +def test_pip_fallback_command(monkeypatch: pytest.MonkeyPatch): + # Without uv, the test's own pip is used, and it only accepts --python + # before the command. + monkeypatch.setattr(shutil, 'which', _no_uv) + python = pathlib.Path('/env/bin/python') + pip = [sys.executable, '-m', 'pip', '--python', str(python)] + assert _charm_requirements._pip(python, 'install', '--no-deps', 'a') == [ + *pip, + 'install', + '--quiet', + '--no-deps', + 'a', + ] + assert _charm_requirements._pip(python, 'uninstall', 'ops') == [ + *pip, + 'uninstall', + '--quiet', + '--yes', + 'ops', + ] + + +def test_install_python_plugin_checks_the_requirements_are_complete( + tmp_path: pathlib.Path, venv: pathlib.Path +): + # charmcraft runs pip check after installing with --no-deps, so a + # requirements file that's missing a dependency fails the pack. + wheel = _wheel(tmp_path / 'wheels', 'needy', requires='missing') + root = _charmcraft( + tmp_path, + 'parts:\n charm:\n plugin: python\n source: .\n' + f' python-packages: [wheels/{wheel}]\n', + ) + with pytest.raises(_RequirementsError, match='missing'): + _charm_requirements.install_charm_requirements(root, venv) + + +def test_install_refuses_the_tests_own_environment(tmp_path: pathlib.Path): + root = _charmcraft(tmp_path, 'parts:\n charm:\n plugin: python\n') + with pytest.raises(_RequirementsError, match="test's own environment"): + _charm_requirements.install_charm_requirements(root, pathlib.Path(sys.executable)) + + +def test_install_with_no_dependencies(tmp_path: pathlib.Path, venv: pathlib.Path): + root = _charmcraft(tmp_path, 'parts:\n charm:\n plugin: python\n') + _charm_requirements.install_charm_requirements(root, venv) + assert _installed(venv) == []