Repository navigation
feat: find a charm's Python dependencies from its build plugin for ops.testing #2880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tonyandrewmeyer
wants to merge
13
commits into
canonical:main
Choose a base branch
from
tonyandrewmeyer:saddle/split-13-charm-requirements
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
78cbb33
feat(testing): find a charm's Python dependencies from its build plugin
tonyandrewmeyer b672ffa
fix(testing): leave charm libraries' PYDEPS out, as charmcraft's pyth…
tonyandrewmeyer 3b2b739
fix(testing): leave out ops-tracing, and require charmcraft.yaml when…
tonyandrewmeyer df5258b
fix: handle extensions, editable ops and attached options when findin…
tonyandrewmeyer 613ce34
fix: install charm dependencies with charmcraft's own pip and uv argu…
tonyandrewmeyer 8ff1e8a
fix: correct Sphinx autoclass directive and _RequirementsError base c…
tonyandrewmeyer c74554e
fix: refuse a charm with a part using an unknown build plugin
tonyandrewmeyer 1be5cbc
fix: refuse to install a charm's dependencies into the test's environ…
tonyandrewmeyer a0488fe
test: check the pip command used when uv isn't available
tonyandrewmeyer 7c75389
fix: run pip check after installing a python-plugin charm's requirements
tonyandrewmeyer 6f1f15c
fix: allow a part source up to the git repository root, and no further
tonyandrewmeyer 3a72e34
fix: show a failed command's stdout as well as its stderr
tonyandrewmeyer d07080b
docs: say who makes the test's ops importable to the charm
tonyandrewmeyer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,302 @@ | ||
| # 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. | ||
|
|
||
| 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. | ||
| _OTHER_PLUGINS = ('nil', 'dump', 'make', 'autotools', 'cmake', 'go', 'rust', 'npm') | ||
|
|
||
| #: 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.""" | ||
|
|
||
|
|
||
| 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. | ||
|
|
||
| Raises: | ||
| _RequirementsError: if the charm's dependencies can't be found (see | ||
| :func:`charm_requirements`), or installing them fails. | ||
| """ | ||
| requirements = charm_requirements(charm_root) | ||
| if requirements.args: | ||
| install = _pip(python, 'install', '--no-deps', *requirements.args) | ||
| _run(install, cwd=requirements.directory) | ||
| _run(_pip(python, 'uninstall', *_OPS_DISTRIBUTIONS)) | ||
|
|
||
|
|
||
| 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: | ||
| raise _RequirementsError(f'`{shlex.join(cmd)}` failed:\n{result.stderr.strip()}\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)) | ||
|
|
||
|
|
||
| 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 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 | ||
| listed = f' Its parts use: {", ".join(unknown)}.' if unknown else '' | ||
| raise _RequirementsError(f'No part in {path} uses the python or uv plugin.{listed}') | ||
|
|
||
|
|
||
| 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``.""" | ||
| 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_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 _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()]) | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Drive-by.