Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
4ee7643
Initial implementation.
tonyandrewmeyer Mar 29, 2025
da8d265
Merge origin/main.
tonyandrewmeyer Mar 29, 2025
7f143cd
Tox is green, including docs.
tonyandrewmeyer Mar 30, 2025
739c20c
Add extra tests.
tonyandrewmeyer Mar 30, 2025
2c9bb9f
Tox is green again.
tonyandrewmeyer Mar 30, 2025
a1d4f74
Add a Raises section.
tonyandrewmeyer Mar 30, 2025
fac6c7c
Expand to-do list.
tonyandrewmeyer Mar 31, 2025
f155bf5
Adjustments after initial review.
tonyandrewmeyer Apr 24, 2025
ea10a4d
Provide pydantic for static checks.
tonyandrewmeyer Apr 24, 2025
bd2a2df
Changes from initial review.
tonyandrewmeyer Apr 24, 2025
eb2d6e1
Merge from main.
tonyandrewmeyer Apr 24, 2025
35b643a
Tweaks after merge.
tonyandrewmeyer Apr 24, 2025
441c68a
Add TODO.
tonyandrewmeyer Apr 24, 2025
0966028
Remove ignore used during dev.
tonyandrewmeyer Apr 28, 2025
29875b1
Add TODO note.
tonyandrewmeyer Apr 29, 2025
f13ed9d
Do the status set right at the end, so that it only happens if charms…
tonyandrewmeyer Apr 30, 2025
4320d97
Tweak comment.
tonyandrewmeyer Apr 30, 2025
84ba899
Use cleaner methods to get the set of fields.
tonyandrewmeyer Apr 30, 2025
b13c742
Only pass the config that the class defines. This makes it easier to …
tonyandrewmeyer Apr 30, 2025
30573f9
Provide lazy Secrets in the config - this means you get errors later …
tonyandrewmeyer Apr 30, 2025
172c692
With lazy Secrets, we no longer get errors with missing secrets/inval…
tonyandrewmeyer Apr 30, 2025
3ee1807
Clean up the attr-to-type code.
tonyandrewmeyer Apr 30, 2025
5f8db50
Add a test for the 'only load some config' case.
tonyandrewmeyer Apr 30, 2025
d932a97
Move TODO to a better location.
tonyandrewmeyer Apr 30, 2025
80dfa63
Merge origin/main.
tonyandrewmeyer Apr 30, 2025
197431a
Add tests for uncaught InvalidSchemaError.
tonyandrewmeyer Apr 30, 2025
d0b7e2e
Fix docs.
tonyandrewmeyer Apr 30, 2025
1c09153
Adjustments from spec review.
tonyandrewmeyer May 8, 2025
b716878
Merge origin/main.
tonyandrewmeyer May 8, 2025
97cd70a
Provide Python 3.8 support.
tonyandrewmeyer May 8, 2025
b3c4941
Apply suggestions from code review
tonyandrewmeyer May 20, 2025
3949ca7
Remove methods for customising attribute names, per review.
tonyandrewmeyer May 20, 2025
0b4a84d
Sort the field names for consistent output.
tonyandrewmeyer May 20, 2025
7f203a1
Move private methods from the class to the module, per review.
tonyandrewmeyer May 20, 2025
734b292
Provide a more explicit type, per review.
tonyandrewmeyer May 20, 2025
5b865f2
Get the default value via pydantic and dataclasses if needed.
tonyandrewmeyer May 21, 2025
ea24f94
Improve comment.
tonyandrewmeyer May 21, 2025
dc61cc9
Simplify the BaseModel case.
tonyandrewmeyer May 21, 2025
bf3b0bd
Use fields() per review.
tonyandrewmeyer May 21, 2025
703938c
Update ops/_private/attrdocs.py
tonyandrewmeyer May 21, 2025
9d614ea
Update ops/charm.py
tonyandrewmeyer May 21, 2025
b929496
Add comment so that when 3.8 support is dropped this is found.
tonyandrewmeyer May 21, 2025
832a6d1
Minor tweak per review.
tonyandrewmeyer May 21, 2025
4294b29
Remove the custom exception.
tonyandrewmeyer May 21, 2025
f4ebc54
Reword docstring.
tonyandrewmeyer May 21, 2025
6248c2c
Merge origin/main
tonyandrewmeyer Jul 7, 2025
86a85d9
Tidy up after merge.
tonyandrewmeyer Jul 7, 2025
d1f1113
Small cleanup.
tonyandrewmeyer Jul 7, 2025
ead796e
Merge origin/main.
tonyandrewmeyer Jul 21, 2025
dac0582
Shuffle things around.
tonyandrewmeyer Aug 8, 2025
48352d6
Merge origin/main.
tonyandrewmeyer Aug 8, 2025
b8986d6
More progress.
tonyandrewmeyer Aug 8, 2025
8659adb
Remaining cleanup.
tonyandrewmeyer Aug 11, 2025
8263595
Add a script that updates charmcraft.yaml.
tonyandrewmeyer Aug 11, 2025
d228ac9
Merge origin/main.
tonyandrewmeyer Aug 11, 2025
76dd056
Add support for releasing ops-tools.
tonyandrewmeyer Aug 11, 2025
9c72564
Remove ignored files.
tonyandrewmeyer Aug 11, 2025
36c3b64
Use pydantic for examples.
tonyandrewmeyer Aug 11, 2025
28b7a47
Fix stripping 'action' from the class name.
tonyandrewmeyer Aug 11, 2025
92880d4
No need to have tools installed for the integration tests at this point.
tonyandrewmeyer Aug 11, 2025
951d91c
Make it more convenient to pass in the modules and classes.
tonyandrewmeyer Aug 11, 2025
bb9e760
Improve the readme with an example to run.
tonyandrewmeyer Aug 11, 2025
3a94b7d
Tweaks from the old review.
tonyandrewmeyer Aug 11, 2025
db5e7c8
Fix docs.
tonyandrewmeyer Aug 11, 2025
8db8ee8
Fix the order of moving through the classes, add an explicit test for…
tonyandrewmeyer Aug 14, 2025
eaa7476
Merge origin/main
tonyandrewmeyer Aug 14, 2025
b901a0a
Make the entry point name clearer.
tonyandrewmeyer Aug 14, 2025
335c01c
Allow merging rather than replacing.
tonyandrewmeyer Aug 14, 2025
71d14d6
Add a diff method.
tonyandrewmeyer Aug 14, 2025
a5b5dc5
WiP doctests.
tonyandrewmeyer Aug 15, 2025
b2efed5
Edit the charmcraft.yaml in a way that preserves comments and ordering.
tonyandrewmeyer Aug 18, 2025
b485a50
Update for newer pyright.
tonyandrewmeyer Aug 18, 2025
47e425b
Merge origin/main.
tonyandrewmeyer Aug 18, 2025
f888063
Fix order of output.
tonyandrewmeyer Aug 19, 2025
36aa0e0
Update tools/README.md
tonyandrewmeyer Aug 27, 2025
9044d61
Apply suggestions from code review
tonyandrewmeyer Aug 27, 2025
89cac58
Tweak args, per review.
tonyandrewmeyer Aug 27, 2025
851956c
Merge origin/main.
tonyandrewmeyer Aug 27, 2025
181ce73
Apply suggestions from code review
tonyandrewmeyer Aug 27, 2025
3d26c4b
Apply suggestions from code review
tonyandrewmeyer Sep 3, 2025
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ coverage.xml
.coverage.data
/.tox
.*.swp
.ruff_cache

# Tokens and settings for `act` to run GHA locally
.env
Expand All @@ -27,6 +28,9 @@ coverage.xml
ops_scenario.egg-info
/testing/build/
/testing/dist/
ops_tools.egg-info
/tools/build/
/tools/dist/

# Smoke test artifacts
*.tar.gz
Expand Down
10 changes: 10 additions & 0 deletions .sbomber-manifest-sdist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ artifacts:
version: ''
channel: 'stable'
cycle: '25.10'

- name: 'ops-tools'
type: 'sdist'
version: ''
compression: 'gz'
ssdlc_params:
name: 'ops-tools'
version: ''
channel: 'stable'
cycle: '25.10'
8 changes: 8 additions & 0 deletions .sbomber-manifest-wheel.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ artifacts:
version: ''
channel: 'stable'
cycle: '25.10'

- name: 'ops-tools'
type: 'wheel'
ssdlc_params:
name: 'ops-tools'
version: ''
channel: 'stable'
cycle: '25.10'
19 changes: 10 additions & 9 deletions HACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,13 @@ Then, check out the main branch of your forked operator repo and pull upstream t
1. Draft a release: Run: `tox -e draft-release` at the root directory of the forked repo.

> This assumes a draft release on the main branch, and your forked remote name is `origin`, and the `canonical/operator` remote name is `upstream`.
>
>
> If you have different settings, add parameters accordingly. For example, the following command assumes your forked remote name is `mine`, and `canonical/operator` remote name is `origin`:
>
>
> `tox -e draft-release -- --canonical-remote origin --fork-remote mine`
>
>
> By default, the script makes a release on the main branch. If you want to make a release on another branch, for example, on "2.23-maintenance" (you do not need to switch to this branch in your forked repo), run it with the "--branch" parameter:
>
>
> `tox -e draft-release -- --branch 2.23-maintenance`

2. Follow the steps of the `tox -e draft-release` output. You need to input the release title and an introduction section, which can be multiple paragraphs with empty lines in between. End the introduction section by typing a period sign (.) in a new line, then press enter.
Expand All @@ -258,11 +258,12 @@ Then, check out the main branch of your forked operator repo and pull upstream t
> You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).

> Pushing the tags will trigger automatic builds for the Python packages and
> publish them to PyPI ([ops](https://pypi.org/project/ops/)
> ,[ops-scenario](https://pypi.org/project/ops-scenario), and
> [ops-tracing](https://pypi.org/project/ops-tracing/)).
> publish them to PyPI ([ops](https://pypi.org/project/ops/)
> ,[ops-scenario](https://pypi.org/project/ops-scenario),
> [ops-tracing](https://pypi.org/project/ops-tracing/)), and
> [ops-tools](https://pypi.org/project/ops-tools/)).
> Note that it sometimes take a bit of time for the new releases to show up.
>
>
> See [.github/workflows/publish.yaml](.github/workflows/publish.yaml) for details.
>
> You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).
Expand All @@ -273,7 +274,7 @@ Then, check out the main branch of your forked operator repo and pull upstream t
7. Post release: At the root directory of your forked `canonical/operator` repo, check out to the main branch to ensure the release automation script is up-to-date, then run: `tox -e post-release`.

> This assumes the same defaults as mentioned in step 1.
>
>
> Add parameters accordingly if your setup differs, for example, if you are releasing from a maintenance branch.

8. Follow the steps of the `tox -e post-release` output. If it succeeds, a PR named "chore: adjust versions after release" will be created. Get it reviewed and merged.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ pebble
ops-testing
ops-testing-harness
ops-tracing
ops-tools
```

7 changes: 7 additions & 0 deletions docs/reference/ops-tools.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.. _ops_tools:

`ops_tools`
===========

.. automodule:: ops_tools
:exclude-members: main
13 changes: 10 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ lint = [
]
docs = [
"ops[testing,tracing]",
"ops-tools",
"canonical-sphinx[full]",
"packaging",
"sphinxcontrib-svg2pdfconverter[CairoSVG]",
Expand All @@ -53,6 +54,7 @@ docs = [
]
unit = [
"ops[testing,tracing]",
"ops-tools",
"pytest~=8.4",
"jsonpatch~=1.33",
"pydantic~=2.10",
Expand Down Expand Up @@ -92,12 +94,13 @@ requires = [
build-backend = "setuptools.build_meta"

[tool.uv.workspace]
members = ["tracing", "testing"]
members = ["tracing", "testing", "tools"]

[tool.uv.sources]
ops = { workspace = true }
ops-scenario = { workspace = true }
ops-tracing = { workspace = true }
ops-tools = { workspace = true }

[tool.setuptools.packages.find]
include = ["ops", "ops._private", "ops.lib"]
Expand Down Expand Up @@ -273,6 +276,10 @@ exclude = ["tracing/ops_tracing/vendor/*"]
"RUF015", # Prefer `next` over single element slice
"SIM115", # Use a context manager for opening files
]
"tools/tests_tools/*" = [
# All documentation linting.
"D",
]
"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 Expand Up @@ -300,9 +307,9 @@ convention = "google"
builtins-ignorelist = ["id", "min", "map", "range", "type", "TimeoutError", "ConnectionError", "Warning", "input", "format"]

[tool.pyright]
include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py", "testing/src/*.py"]
include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py", "testing/src/*.py", "tools/src/*.py", "tools/tests_tools/*.py"]
exclude = ["tracing/*"]
extraPaths = ["testing", "tracing"]
extraPaths = ["testing", "tracing", "tools"]
pythonVersion = "3.10" # check no python > 3.10 features are used
pythonPlatform = "All"
typeCheckingMode = "strict"
Expand Down
10 changes: 10 additions & 0 deletions release.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
'ops/src': pathlib.Path('ops/version.py'),
'ops/pyproject': pathlib.Path('pyproject.toml'),
'testing': pathlib.Path('testing/pyproject.toml'),
'tools': pathlib.Path('tools/pyproject.toml'),
'tracing': pathlib.Path('tracing/pyproject.toml'),
'uvlock': pathlib.Path('uv.lock'),
}
Expand Down Expand Up @@ -386,6 +387,13 @@ def update_tracing_version(ops_version: str):
update_pyproject_versions(VERSION_FILES['tracing'], ops_version, deps={'ops': ops_version})


def update_tools_version(ops_version: str):
"""Update the tools pyproject version."""
major, rest = ops_version.split('.', 1)
tools_version = f'{int(major) - 2}.{rest}'
update_pyproject_versions(VERSION_FILES['tools'], tools_version, deps={})


def update_uv_lock():
"""Update the uv.lock file with the new versions."""
subprocess.run(['uv', 'lock'], check=True) # noqa: S607
Expand Down Expand Up @@ -421,6 +429,7 @@ def update_versions_for_release(tag: str):
update_ops_version(tag, scenario_version)
update_testing_version(tag, scenario_version)
update_tracing_version(tag)
update_tools_version(tag)
update_uv_lock()


Expand Down Expand Up @@ -464,6 +473,7 @@ def update_versions_for_post_release(repo: github.Repository.Repository, branch_
update_ops_version(ops_version, scenario_version)
update_testing_version(ops_version, scenario_version)
update_tracing_version(ops_version)
update_tools_version(ops_version)
update_uv_lock()


Expand Down
1 change: 1 addition & 0 deletions test/charms/test_main/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
# during unit tests, and test_main failures that subprocess out are often
# difficult to debug. Uncomment this line to get more informative errors when
# running the tests.
# When uncommented the test_hook_and_dispatch_with_failing_hook test will fail.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit could be perhaps written as "test expected to fail if uncommented".

# logger.addHandler(logging.StreamHandler(sys.stderr))


Expand Down
4 changes: 4 additions & 0 deletions testing/src/scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1975,6 +1975,10 @@ def autoload(charm_type: type[CharmBase]) -> _CharmSpec[CharmType]:
# try to load using legacy metadata.yaml/actions.yaml/config.yaml files
meta, config, actions = _CharmSpec._load_metadata_legacy(charm_root)

# TODO: ideally, we would look for ConfigBase classes in the charm
# module and autoload from there at this point. Leaving this until the
# conversation about if & how the generation is done is resolved.

Comment on lines +1978 to +1981
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My feeling at the moment is that this should be left for future work, rather than baking anything in to Scenario at this time. There is a simple example of using this functionality in tests for both config and actions in the Scenario tests - I think those show that it's not too arduous to use at the moment, without anything else.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not getting the point of this comment.
Specifically, if I were to read it a few years later, I would be missing the context.

Could future work be tracked in GitHub issues or Jira tickets or roadmap items instead?

if not meta:
# still no metadata? bug out
raise MetadataNotFoundError(
Expand Down
27 changes: 27 additions & 0 deletions testing/tests/test_e2e/test_actions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import dataclasses

import ops_tools
import pytest
from ops import __version__ as ops_version
from ops.charm import ActionEvent, CharmBase
Expand Down Expand Up @@ -203,3 +206,27 @@ def test_default_arguments():
assert action.name == name
assert action.params == {}
assert action.id == expected_id


def test_action_using_generated_action():
@dataclasses.dataclass
class Act:
a: int
b: float
c: str

class Charm(CharmBase):
def __init__(self, framework: Framework):
super().__init__(framework)
framework.observe(self.on['act'].action, self._on_action)

def _on_action(self, event: ActionEvent):
self.typed_params = event.load_params(Act, 10, c='foo')

schema = ops_tools.action_to_juju_schema(Act)
ctx = Context(Charm, meta={'name': 'foo'}, actions=schema)
with ctx(ctx.on.action('act', params={'b': 3.14}), State()) as mgr:
mgr.run()
assert mgr.charm.typed_params.a == 10
assert mgr.charm.typed_params.b == 3.14
assert mgr.charm.typed_params.c == 'foo'
48 changes: 38 additions & 10 deletions testing/tests/test_e2e/test_config.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
from __future__ import annotations

import dataclasses

import pytest
from ops.charm import CharmBase
from ops.framework import Framework

import ops
import ops_tools

from scenario.context import Context
from scenario.state import State
from ..helpers import trigger


@pytest.fixture(scope='function')
def mycharm():
class MyCharm(CharmBase):
def __init__(self, framework: Framework):
class MyCharm(ops.CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
for evt in self.on.events().values():
self.framework.observe(evt, self._on_event)
framework.observe(evt, self._on_event)

def _on_event(self, event):
pass
Expand All @@ -23,7 +27,7 @@ def _on_event(self, event):


def test_config_get(mycharm):
def check_cfg(charm: CharmBase):
def check_cfg(charm: ops.CharmBase):
assert charm.config['foo'] == 'bar'
assert charm.config['baz'] == 1

Expand All @@ -40,7 +44,7 @@ def check_cfg(charm: CharmBase):


def test_config_get_default_from_meta(mycharm):
def check_cfg(charm: CharmBase):
def check_cfg(charm: ops.CharmBase):
assert charm.config['foo'] == 'bar'
assert charm.config['baz'] == 2
assert charm.config['qux'] is False
Expand Down Expand Up @@ -72,11 +76,11 @@ def check_cfg(charm: CharmBase):
),
)
def test_config_in_not_mutated(mycharm, cfg_in):
class MyCharm(CharmBase):
def __init__(self, framework: Framework):
class MyCharm(ops.CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
for evt in self.on.events().values():
self.framework.observe(evt, self._on_event)
framework.observe(evt, self._on_event)

def _on_event(self, event):
# access the config to trigger a config-get
Expand All @@ -101,3 +105,27 @@ def _on_event(self, event):
)
# check config was not mutated by scenario
assert state_out.config == cfg_in


def test_config_using_generated_config():
@dataclasses.dataclass
class Config:
a: int
b: float
c: str

class Charm(ops.CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
framework.observe(self.on.config_changed, self._on_config_changed)

def _on_config_changed(self, event: ops.ConfigChangedEvent):
self.typed_config = self.load_config(Config, 10, c='foo')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question this is unexpected to me.

I would have thought that default are specified in a model, not in the load config call site.

The defaults can be included in both dataclasses and pydantic models, so why add the extra arguments feature in the load_config method?

P.S. maybe I was lazy reading the spec, but somehow I don't recall this API 🙈

P.P.S. I guess it's because Juju action parameters don't have defaults like Juju config does, isn't it?

That also means that we should be testing both required and non-required parameters somewhere... perhaps a negative test in Scenario?


schema = ops_tools.config_to_juju_schema(Config)
ctx = Context(Charm, meta={'name': 'foo'}, config=schema)
with ctx(ctx.on.config_changed(), State(config={'b': 3.14})) as mgr:
mgr.run()
assert mgr.charm.typed_config.a == 10
assert mgr.charm.typed_config.b == 3.14
assert mgr.charm.typed_config.c == 'foo'
Loading