Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
180 changes: 180 additions & 0 deletions .github/generate_charmcraft_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env -S uv run --script --no-project
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml"]
# ///

# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

"""Generate testing/src/scenario/_charmcraft_extensions.py.

For each charmcraft extension profile (django-framework, fastapi-framework, etc.),
this script runs `charmcraft init` and `charmcraft expand-extensions` in a temp
directory, then extracts the metadata, config, and actions that the extension adds.

The output module contains three dictionaries per extension:
METADATA: dict[str, dict] - containers, peers, provides, requires, resources, assumes
CONFIG: dict[str, dict] - config options added by each extension
ACTIONS: dict[str, dict] - actions added by each extension
"""

from __future__ import annotations

import pathlib
import subprocess
import sys
import tempfile
from typing import Any

import yaml

# Keys from the expanded YAML that belong to "metadata" (the charm's metadata.yaml equivalent).
METADATA_KEYS = {
'assumes',
'containers',
'peers',
'provides',
'requires',
'resources',
}

# Keys that are user-provided or build-related (not extension-contributed metadata).
IGNORED_KEYS = {
'name',
'summary',
'description',
'type',
'bases',
'base',
'platforms',
'extensions',
'parts',
'charm-libs',
}

OUTPUT_FILE = (
pathlib.Path(__file__).resolve().parent.parent
/ 'testing'
/ 'src'
/ 'scenario'
/ '_charmcraft_extensions.py'
)
Comment thread
tonyandrewmeyer marked this conversation as resolved.


def get_extensions() -> list[str]:
"""Get the list of available charmcraft extensions via ``charmcraft list-extensions``."""
result = subprocess.check_output(['charmcraft', 'list-extensions'], text=True)
extensions = []
for line in result.splitlines():
# Skip header and separator lines.
if not line or line.startswith('Extension') or line.startswith('---'):
continue
name = line.split()[0]
extensions.append(name)
return sorted(extensions)


def run_charmcraft(profile: str, workdir: pathlib.Path) -> dict[str, Any]:
"""Run charmcraft init + expand-extensions and return the expanded YAML as a dict."""
subprocess.check_call(
['charmcraft', 'init', '--profile', profile, '--name', 'test-charm'],
cwd=workdir,
)
output = subprocess.check_output(
['charmcraft', 'expand-extensions'],
cwd=workdir,
text=True,
)
return yaml.safe_load(output)


def extract_extension_data(
expanded: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
"""Extract metadata, config options, and actions from expanded charmcraft YAML."""
metadata = {key: expanded[key] for key in METADATA_KEYS if key in expanded}
config = expanded.get('config', {}).get('options', {})
actions: dict[str, Any] = expanded.get('actions', {})

return metadata, config, actions


def generate_module(
all_data: dict[str, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]],
) -> str:
"""Generate the Python module source code."""
lines = [
'# Copyright 2026 Canonical Ltd.',
'# See LICENSE file for licensing details.',
'"""Charmcraft extension metadata, config, and actions.',
'',
'Auto-generated by .github/generate_charmcraft_extensions.py',
'Do not edit manually.',
'"""',
'',
'from __future__ import annotations',
'',
'from typing import Any, TypedDict',
'',
'',
'class _ExtensionMetadata(TypedDict, total=False):',
' assumes: list[str]',
' containers: dict[str, Any]',
' peers: dict[str, Any]',
' provides: dict[str, Any]',
' requires: dict[str, Any]',
' resources: dict[str, Any]',
'',
'',
]

# Build the three top-level dicts, pre-sorting keys.
metadata_entries: dict[str, dict[str, Any]] = {}
config_entries: dict[str, dict[str, Any]] = {}
action_entries: dict[str, dict[str, Any]] = {}
for profile, (metadata, config, actions) in sorted(all_data.items()):
metadata_entries[profile] = {k: metadata[k] for k in sorted(metadata)}
config_entries[profile] = {k: config[k] for k in sorted(config)}
action_entries[profile] = {k: actions[k] for k in sorted(actions)}

for var_name, data, type_str, doc_name in [
('METADATA', metadata_entries, 'dict[str, _ExtensionMetadata]', 'Metadata'),
('CONFIG', config_entries, 'dict[str, dict[str, Any]]', 'Config options'),
('ACTIONS', action_entries, 'dict[str, dict[str, Any]]', 'Actions'),
]:
lines.append(f'# {doc_name} added by each charmcraft extension.')
lines.append(f'{var_name}: {type_str} = {data!r}')
lines.append('')

return '\n'.join(lines)


def main() -> int: # noqa: D103
extensions = get_extensions()
print(f'Found extensions: {", ".join(extensions)}')

all_data: dict[str, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = {}

for profile in extensions:
print(f'Processing {profile}...')
with tempfile.TemporaryDirectory() as tmpdir:
expanded = run_charmcraft(profile, pathlib.Path(tmpdir))
all_data[profile] = extract_extension_data(expanded)

module_source = generate_module(all_data)
OUTPUT_FILE.write_text(module_source)
print(f'Written to {OUTPUT_FILE}')

print('Running tox -e format...')
subprocess.run(
['tox', '-e', 'format', '--', str(OUTPUT_FILE)],
cwd=OUTPUT_FILE.parent,
check=True,
)

return 0


if __name__ == '__main__':
sys.exit(main())
Comment thread
tonyandrewmeyer marked this conversation as resolved.
26 changes: 26 additions & 0 deletions docs/explanation/state-transition-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,32 @@ ctx = testing.Context(
state = ctx.run(ctx.on.start(), testing.State())
```

## Charmcraft extensions

If your `charmcraft.yaml` uses a charmcraft extension such as `flask-framework`
or `django-framework`, the testing framework will automatically expand it when
automatically loading metadata. The extension's metadata (containers, relations, resources,
and so on), config options, and actions are merged into the charm spec, simulating what
`charmcraft expand-extensions` does at pack time.

This means you can write tests against a charm that uses extensions without having
to manually specify all the metadata the extension adds:

```python
# Given a charmcraft.yaml with:
# extensions:
# - flask-framework

ctx = testing.Context(MyFlaskCharm)
# The 'ingress' relation is provided by the flask-framework extension.
state = ctx.run(ctx.on.start(), testing.State(relations={testing.Relation('ingress')}))
```

If your `charmcraft.yaml` defines keys that overlap with what the extension
provides (for example, a config option or relation with the same name), both
`charmcraft pack` and the testing framework will raise an error. Rename or
remove the overlapping keys to fix this.

## Immutability

All of the data structures in the state, (`State`, `Relation`, `Container`, and
Expand Down
12 changes: 12 additions & 0 deletions docs/howto/write-unit-tests-for-a-charm.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ def test_peer_changed():

> See more: [](ops.testing.State.from_context)

```{note}
If your `charmcraft.yaml` uses a charmcraft extension (e.g.
`extensions: [flask-framework]`), the metadata, config, and actions that
the extension adds are automatically merged in when the testing framework
loads the charm spec. You do not need to manually specify them.

If your `charmcraft.yaml` defines keys that overlap with what the extension
provides (e.g. a config option with the same name), the testing framework
will raise a `ValueError`, matching the behaviour of `charmcraft pack`.
Rename or remove the overlapping keys to fix this.
```

## Mock beyond the State

If you wish to use the framework to test an existing charm type, you will probably need to mock out certain calls that are not covered by the `State` data structure. In that case, you will have to manually mock, patch or otherwise simulate those calls.
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ exclude = ["tracing/ops_tracing/vendor/*"]
# All documentation linting.
"D",
]
"testing/src/scenario/_charmcraft_extensions.py" = [
"E501", # Auto-generated file; long description strings are unavoidable.
]
".github/generate_charmcraft_extensions.py" = [
"S607", # Intentionally calls charmcraft/tox by name.
]
"ops/_private/timeconv.py" = [
"RUF001", # String contains ambiguous `µ` (MICRO SIGN). Did you mean `μ` (GREEK SMALL LETTER MU)?
"RUF002", # Docstring contains ambiguous `µ` (MICRO SIGN). Did you mean `μ` (GREEK SMALL LETTER MU)?
Expand Down
Loading