From 7f7c07d4d7bf7acd04dbc3f30873ee50abb1e05c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 30 Aug 2024 16:46:11 +1200 Subject: [PATCH] docs: expand the reference documentation (#169) Expands the docstrings so that when used with Sphinx's autodoc they will fit in with the other ops docs on ops.rtd but also still work standalone. The Sphinx autodoc system uses the `__new__` signature in preference to the `__init__` one, which means that by default all classes that are using the `_MaxPositionalArgs` class have a `*args, **kwargs` signature, which is not informative. custom_conf.py monkeypatches Sphinx to work around this, including tweaking the signature so that the `*` appears in the correct place for the maximum number of positional arguments. Also bumps the Sphinx version to align with ops. --------- Co-authored-by: PietroPasotti --- docs/custom_conf.py | 26 +++- docs/index.rst | 18 +-- docs/requirements.txt | 100 +++++++++------- pyproject.toml | 2 +- scenario/__init__.py | 59 ++++++++- scenario/_consistency_checker.py | 79 +++++++----- scenario/context.py | 200 +++++++++++++++++++++---------- scenario/runtime.py | 2 +- scenario/state.py | 193 ++++++++++++++++++++++------- 9 files changed, 491 insertions(+), 188 deletions(-) diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 70bf3e10..a4993d4b 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -31,6 +31,7 @@ def _compute_navigation_tree(context): r'''^ ([\w.]+::)? # explicit module name ([\w.]+\.)? # module and/or class name(s) ([^.()]+) \s* # thing name + (?: \[\s*(.*)\s*])? # optional: type parameters list, Sphinx 7&8 (?: \((.*)\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -306,9 +307,32 @@ def _compute_navigation_tree(context): # ('envvar', 'LD_LIBRARY_PATH'). nitpick_ignore = [ # Please keep this list sorted alphabetically. - ('py:class', '_CharmSpec'), ('py:class', '_Event'), + ('py:class', '_EntityStatus'), + ('py:class', 'ModelError'), # This is in a copied docstring so we can't fix it. ('py:class', 'scenario.state._EntityStatus'), ('py:class', 'scenario.state._Event'), ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] + +# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses +# of _MaxPositionalArgs. +import inspect +import sphinx.ext.autodoc + +_real_get_signature = sphinx.ext.autodoc.ClassDocumenter._get_signature + +def _custom_get_signature(self): + if any(p.__name__ == '_MaxPositionalArgs' for p in self.object.__mro__): + signature = inspect.signature(self.object) + parameters = [] + for position, param in enumerate(signature.parameters.values()): + if position >= self.object._max_positional_args: + parameters.append(param.replace(kind=inspect.Parameter.KEYWORD_ONLY)) + else: + parameters.append(param) + signature = signature.replace(parameters=parameters) + return None, None, signature + return _real_get_signature(self) + +sphinx.ext.autodoc.ClassDocumenter._get_signature = _custom_get_signature diff --git a/docs/index.rst b/docs/index.rst index 272af959..a64e16f7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,21 +1,13 @@ - Scenario API reference ====================== -.. toctree:: - :maxdepth: 2 - :caption: Contents: - -scenario.State -============== - -.. automodule:: scenario.state - +scenario +======== -scenario.Context -================ +.. automodule:: scenario + :special-members: __call__ -.. automodule:: scenario.context +.. automodule:: scenario.errors Indices diff --git a/docs/requirements.txt b/docs/requirements.txt index 7b02bdf0..f517d485 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,53 +1,65 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml # -alabaster==0.7.13 +alabaster==1.0.0 # via sphinx -babel==2.14.0 +anyio==4.4.0 + # via + # starlette + # watchfiles +babel==2.16.0 # via sphinx beautifulsoup4==4.12.3 # via # canonical-sphinx-extensions # furo # pyspelling -bracex==2.4 +bracex==2.5 # via wcmatch -canonical-sphinx-extensions==0.0.19 +canonical-sphinx-extensions==0.0.23 # via ops-scenario (pyproject.toml) -certifi==2024.2.2 +certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests +click==8.1.7 + # via uvicorn colorama==0.4.6 # via sphinx-autobuild -docutils==0.19 +docutils==0.21.2 # via # canonical-sphinx-extensions # myst-parser # sphinx # sphinx-tabs -furo==2024.1.29 +furo==2024.8.6 # via ops-scenario (pyproject.toml) +gitdb==4.0.11 + # via gitpython +gitpython==3.1.43 + # via canonical-sphinx-extensions +h11==0.14.0 + # via uvicorn html5lib==1.1 # via pyspelling -idna==3.6 - # via requests +idna==3.8 + # via + # anyio + # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.3 +jinja2==3.1.4 # via # myst-parser # sphinx linkify-it-py==2.0.3 # via ops-scenario (pyproject.toml) -livereload==2.6.3 - # via sphinx-autobuild -lxml==5.2.1 +lxml==5.3.0 # via pyspelling -markdown==3.6 +markdown==3.7 # via pyspelling markdown-it-py==3.0.0 # via @@ -55,44 +67,46 @@ markdown-it-py==3.0.0 # myst-parser markupsafe==2.1.5 # via jinja2 -mdit-py-plugins==0.4.0 +mdit-py-plugins==0.4.1 # via myst-parser mdurl==0.1.2 # via markdown-it-py -myst-parser==2.0.0 +myst-parser==4.0.0 # via ops-scenario (pyproject.toml) -ops==2.12.0 +ops==2.15.0 # via ops-scenario (pyproject.toml) -packaging==24.0 +packaging==24.1 # via sphinx -pygments==2.17.2 +pygments==2.18.0 # via # furo # sphinx # sphinx-tabs pyspelling==2.10 # via ops-scenario (pyproject.toml) -pyyaml==6.0.1 +pyyaml==6.0.2 # via # myst-parser # ops # ops-scenario (pyproject.toml) # pyspelling -requests==2.31.0 +requests==2.32.3 # via # canonical-sphinx-extensions # sphinx six==1.16.0 - # via - # html5lib - # livereload + # via html5lib +smmap==5.0.1 + # via gitdb +sniffio==1.3.1 + # via anyio snowballstemmer==2.2.0 # via sphinx -soupsieve==2.5 +soupsieve==2.6 # via # beautifulsoup4 # pyspelling -sphinx==6.2.1 +sphinx==8.0.2 # via # canonical-sphinx-extensions # furo @@ -106,43 +120,49 @@ sphinx==6.2.1 # sphinx-tabs # sphinxcontrib-jquery # sphinxext-opengraph -sphinx-autobuild==2024.2.4 +sphinx-autobuild==2024.4.16 # via ops-scenario (pyproject.toml) sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 # via ops-scenario (pyproject.toml) -sphinx-design==0.5.0 +sphinx-design==0.6.1 # via ops-scenario (pyproject.toml) -sphinx-notfound-page==1.0.0 +sphinx-notfound-page==1.0.4 # via ops-scenario (pyproject.toml) sphinx-tabs==3.4.5 # via ops-scenario (pyproject.toml) -sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jquery==4.1 # via ops-scenario (pyproject.toml) sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphinxext-opengraph==0.9.1 # via ops-scenario (pyproject.toml) -tornado==6.4 - # via livereload +starlette==0.38.2 + # via sphinx-autobuild uc-micro-py==1.0.3 # via linkify-it-py -urllib3==2.2.1 +urllib3==2.2.2 # via requests -wcmatch==8.5.1 +uvicorn==0.30.6 + # via sphinx-autobuild +watchfiles==0.24.0 + # via sphinx-autobuild +wcmatch==9.0 # via pyspelling webencodings==0.5.1 # via html5lib -websocket-client==1.7.0 +websocket-client==1.8.0 # via ops +websockets==13.0.1 + # via sphinx-autobuild diff --git a/pyproject.toml b/pyproject.toml index 801e263f..03ffdfc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ docs = [ "linkify-it-py", "myst-parser", "pyspelling", - "sphinx==6.2.1", + "sphinx ~= 8.0.0", "sphinx-autobuild", "sphinx-copybutton", "sphinx-design", diff --git a/scenario/__init__.py b/scenario/__init__.py index 3439daa1..2ba5a24c 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -2,6 +2,64 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +"""Charm state-transition testing SDK for Ops charms. + +Write tests that declaratively define the Juju state all at once, define the +Juju context against which to test the charm, and fire a single event on the +charm to execute its logic. The tests can then assert that the Juju state has +changed as expected. + +These tests are 'state-transition' tests, a way to test isolated units of charm +functionality (how the state changes in reaction to events). They are not +necessarily tests of individual methods or functions (but might be, depending on +the charm's event observers); they are testing the 'contract' of the charm: given +a certain state, when a certain event happens, the charm should transition to a +certain (likely different) state. They do not test against a real Juju +controller and model, and focus on a single Juju unit, unlike integration tests. +For simplicity, we refer to them as 'unit' tests in the charm context. + +Writing these tests should nudge you into thinking of a charm as a black-box +input->output function. The input is the union of an `Event` (why am I, charm, +being executed), a `State` (am I leader? what is my integration data? what is my +config?...) and the charm's execution `Context` (what integrations can I have? +what containers can I have?...). The output is another `State`: the state after +the charm has had a chance to interact with the mocked Juju model and affect the +state. + +.. image:: https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png + :alt: Transition diagram, with the input state and event on the left, the context including the charm in the centre, and the state out on the right + +Writing unit tests for a charm, then, means verifying that: + +- the charm does not raise uncaught exceptions while handling the event +- the output state (as compared with the input state) is as expected. + +A test consists of three broad steps: + +- **Arrange**: + - declare the context + - declare the input state +- **Act**: + - select an event to fire + - run the context (i.e. obtain the output state, given the input state and the event) +- **Assert**: + - verify that the output state (as compared with the input state) is how you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + - optionally, you can use a context manager to get a hold of the charm instance and run + assertions on APIs and state internal to it. + +The most basic scenario is one in which all is defaulted and barely any data is +available. The charm has no config, no integrations, no leadership, and its +status is `unknown`. With that, we can write the simplest possible test: + +.. code-block:: python + + def test_base(): + ctx = Context(MyCharm) + out = ctx.run(ctx.on.start(), State()) + assert out.unit_status == UnknownStatus() +""" + from scenario.context import Context, Manager from scenario.state import ( ActionFailed, @@ -87,5 +145,4 @@ "UnitID", "UnknownStatus", "WaitingStatus", - "deferred", ] diff --git a/scenario/_consistency_checker.py b/scenario/_consistency_checker.py index 68fd3c24..748806ff 100644 --- a/scenario/_consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -1,6 +1,23 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +""" +The :meth:`check_consistency` function is the primary entry point for the +consistency checks. Calling it ensures that the :class:`State` and the event, +in combination with the ``Context``, is viable in Juju. For example, Juju can't +emit a ``foo-relation-changed`` event on your charm unless your charm has +declared a ``foo`` relation endpoint in its metadata. + +Normally, there is no need to explicitly call this function; that happens +automatically behind the scenes whenever you trigger an event. + +If you have a clear false negative, are explicitly testing 'edge', +inconsistent situations, or for whatever reason the checker is in your way, you +can set the ``SCENARIO_SKIP_CONSISTENCY_CHECKS`` environment variable and skip +it altogether. +""" + import marshal import os import re @@ -26,7 +43,11 @@ class Results(NamedTuple): - """Consistency checkers return type.""" + """Consistency checker return type. + + Each consistency check function returns a ``Results`` instance with the + warnings and errors found during the check. + """ errors: Iterable[str] warnings: Iterable[str] @@ -38,20 +59,22 @@ def check_consistency( charm_spec: "_CharmSpec", juju_version: str, ): - """Validate the combination of a state, an event, a charm spec, and a juju version. - - When invoked, it performs a series of checks that validate that the state is consistent with - itself, with the event being emitted, the charm metadata, etc... - - This function performs some basic validation of the combination of inputs that goes into a - scenario test and determines if the scenario is a realistic/plausible/consistent one. - - A scenario is inconsistent if it can practically never occur because it contradicts - the juju model. - For example: juju guarantees that upon calling config-get, a charm will only ever get the keys - it declared in its config.yaml. - So a State declaring some config keys that are not in the charm's config.yaml is nonsense, - and the combination of the two is inconsistent. + """Validate the combination of a state, an event, a charm spec, and a Juju version. + + When invoked, it performs a series of checks that validate that the state is + consistent with itself, with the event being emitted, the charm metadata, + and so on. + + This function performs some basic validation of the combination of inputs + that goes into a test and determines if the scenario is a + realistic/plausible/consistent one. + + A scenario is inconsistent if it can practically never occur because it + contradicts the Juju model. For example: Juju guarantees that upon calling + ``config-get``, a charm will only ever get the keys it declared in its + config metadata, so a :class:`scenario.State` declaring some config keys + that are not in the charm's ``charmcraft.yaml`` is nonsense, and the + combination of the two is inconsistent. """ juju_version_: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) @@ -103,7 +126,7 @@ def check_resource_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the resources from metadata and in State.""" + """Check the internal consistency of the resources from metadata and in :class:`scenario.State`.""" errors = [] warnings = [] @@ -125,7 +148,7 @@ def check_event_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the _Event data structure. + """Check the internal consistency of the ``_Event`` data structure. For example, it checks that a relation event has a relation instance, and that the relation endpoint name matches the event prefix. @@ -335,7 +358,7 @@ def check_storages_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" + """Check the consistency of the :class:`scenario.State` storages with the charm_spec metadata.""" state_storage = state.storages meta_storage = (charm_spec.meta or {}).get("storage", {}) errors = [] @@ -373,7 +396,7 @@ def check_config_consistency( juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" + """Check the consistency of the :class:`scenario.State` config with the charm_spec config.""" state_config = state.config meta_config = (charm_spec.config or {}).get("options", {}) errors = [] @@ -381,7 +404,8 @@ def check_config_consistency( for key, value in state_config.items(): if key not in meta_config: errors.append( - f"config option {key!r} in state.config but not specified in config.yaml.", + f"config option {key!r} in state.config but not specified in config.yaml or " + f"charmcraft.yaml.", ) continue @@ -431,7 +455,7 @@ def check_secrets_consistency( juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of Secret-related stuff.""" + """Check the consistency of any :class:`scenario.Secret` in the :class:`scenario.State`.""" errors = [] if not event._is_secret_event: return Results(errors, []) @@ -458,6 +482,7 @@ def check_network_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: + """Check the consistency of any :class:`scenario.Network` in the :class:`scenario.State`.""" errors = [] meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) @@ -474,7 +499,7 @@ def check_network_consistency( state_bindings = {network.binding_name for network in state.networks} if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): errors.append( - f"Some network bindings defined in State are not in metadata.yaml: {diff}.", + f"Some network bindings defined in State are not in the metadata: {diff}.", ) endpoints = {endpoint for endpoint, metadata in all_relations} @@ -493,6 +518,7 @@ def check_relation_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: + """Check the consistency of any relations in the :class:`scenario.State`.""" errors = [] peer_relations_meta = charm_spec.meta.get("peers", {}).items() @@ -562,7 +588,7 @@ def check_containers_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of `state.containers` vs. `charm_spec.meta`.""" + """Check the consistency of :class:`scenario.State` containers with the charm_spec metadata.""" # event names will be normalized; need to compare against normalized container names. meta = charm_spec.meta @@ -625,7 +651,7 @@ def check_cloudspec_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check that Kubernetes charms/models don't have `state.cloud_spec`.""" + """Check that Kubernetes models don't have :attr:`scenario.State.cloud_spec` set.""" errors = [] warnings = [] @@ -633,8 +659,7 @@ def check_cloudspec_consistency( if state.model.type == "kubernetes" and state.model.cloud_spec: errors.append( "CloudSpec is only available for machine charms, not Kubernetes charms. " - "Tell Scenario to simulate a machine substrate with: " - "`scenario.State(..., model=scenario.Model(type='lxd'))`.", + "Simulate a machine substrate with: `State(..., model=Model(type='lxd'))`.", ) return Results(errors, warnings) @@ -645,7 +670,7 @@ def check_storedstate_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of `state.stored_states`.""" + """Check the internal consistency of any :class:`scenario.StoredState` in the :class:`scenario.State`.""" errors = [] # Attribute names must be unique on each object. diff --git a/scenario/context.py b/scenario/context.py index 67759789..08bfdd50 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +import functools import tempfile from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast -from ops import CharmBase, EventBase -from ops.testing import ExecArgs +import ops +import ops.testing from scenario.errors import AlreadyEmittedError, ContextSetupError from scenario.logger import logger as scenario_logger @@ -26,8 +28,6 @@ ) if TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - from scenario.ops_main_mock import Ops from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus @@ -64,12 +64,16 @@ def __init__( self.output: Optional["State"] = None @property - def charm(self) -> CharmBase: + def charm(self) -> ops.CharmBase: + """The charm object instantiated by ops to handle the event. + + The charm is only available during the context manager scope. + """ if not self.ops: raise RuntimeError( "you should __enter__ this context manager before accessing this", ) - return cast(CharmBase, self.ops.charm) + return cast(ops.CharmBase, self.ops.charm) @property def _runner(self): @@ -104,63 +108,87 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 self.run() -class _CharmEvents: - """Events generated by Juju pertaining to application lifecycle. +def _copy_doc(original_func): + """Copy the docstring from `original_func` to the wrapped function.""" + + def decorator(wrapper_func): + @functools.wraps(wrapper_func) + def wrapped(*args, **kwargs): + return wrapper_func(*args, **kwargs) + + wrapped.__doc__ = original_func.__doc__ + return wrapped - By default, the events listed as attributes of this class will be - provided via the :attr:`Context.on` attribute. For example:: + return decorator + + +class CharmEvents: + """Events generated by Juju or ops pertaining to the application lifecycle. + + The events listed as attributes of this class should be accessed via the + :attr:`Context.on` attribute. For example:: ctx.run(ctx.on.config_changed(), state) This behaves similarly to the :class:`ops.CharmEvents` class but is much - simpler as there are no dynamically named attributes, and no __getattr__ + simpler as there are no dynamically named attributes, and no ``__getattr__`` version to get events. In addition, all of the attributes are methods, - which are used to connect the event to the specific container object that - they relate to (or, for simpler events like "start" or "stop", take no - arguments). + which are used to connect the event to the specific object that they relate + to (or, for simpler events like "start" or "stop", take no arguments). """ @staticmethod + @_copy_doc(ops.InstallEvent) def install(): return _Event("install") @staticmethod + @_copy_doc(ops.StartEvent) def start(): return _Event("start") @staticmethod + @_copy_doc(ops.StopEvent) def stop(): return _Event("stop") @staticmethod + @_copy_doc(ops.RemoveEvent) def remove(): return _Event("remove") @staticmethod + @_copy_doc(ops.UpdateStatusEvent) def update_status(): return _Event("update_status") @staticmethod + @_copy_doc(ops.ConfigChangedEvent) def config_changed(): return _Event("config_changed") @staticmethod + @_copy_doc(ops.UpdateStatusEvent) def upgrade_charm(): return _Event("upgrade_charm") @staticmethod + @_copy_doc(ops.PreSeriesUpgradeEvent) def pre_series_upgrade(): return _Event("pre_series_upgrade") @staticmethod + @_copy_doc(ops.PostSeriesUpgradeEvent) def post_series_upgrade(): return _Event("post_series_upgrade") @staticmethod + @_copy_doc(ops.LeaderElectedEvent) def leader_elected(): return _Event("leader_elected") @staticmethod + @_copy_doc(ops.SecretChangedEvent) def secret_changed(secret: Secret): if secret.owner: raise ValueError( @@ -169,6 +197,7 @@ def secret_changed(secret: Secret): return _Event("secret_changed", secret=secret) @staticmethod + @_copy_doc(ops.SecretExpiredEvent) def secret_expired(secret: Secret, *, revision: int): if not secret.owner: raise ValueError( @@ -177,6 +206,7 @@ def secret_expired(secret: Secret, *, revision: int): return _Event("secret_expired", secret=secret, secret_revision=revision) @staticmethod + @_copy_doc(ops.SecretRotateEvent) def secret_rotate(secret: Secret): if not secret.owner: raise ValueError( @@ -185,6 +215,7 @@ def secret_rotate(secret: Secret): return _Event("secret_rotate", secret=secret) @staticmethod + @_copy_doc(ops.SecretRemoveEvent) def secret_remove(secret: Secret, *, revision: int): if not secret.owner: raise ValueError( @@ -194,17 +225,21 @@ def secret_remove(secret: Secret, *, revision: int): @staticmethod def collect_app_status(): + """Event triggered at the end of every hook to collect app statuses for evaluation""" return _Event("collect_app_status") @staticmethod def collect_unit_status(): + """Event triggered at the end of every hook to collect unit statuses for evaluation""" return _Event("collect_unit_status") @staticmethod + @_copy_doc(ops.RelationCreatedEvent) def relation_created(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_created", relation=relation) @staticmethod + @_copy_doc(ops.RelationJoinedEvent) def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_joined", @@ -213,6 +248,7 @@ def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = No ) @staticmethod + @_copy_doc(ops.RelationChangedEvent) def relation_changed( relation: "RelationBase", *, @@ -225,6 +261,7 @@ def relation_changed( ) @staticmethod + @_copy_doc(ops.RelationDepartedEvent) def relation_departed( relation: "RelationBase", *, @@ -239,22 +276,27 @@ def relation_departed( ) @staticmethod + @_copy_doc(ops.RelationBrokenEvent) def relation_broken(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_broken", relation=relation) @staticmethod + @_copy_doc(ops.StorageAttachedEvent) def storage_attached(storage: Storage): return _Event(f"{storage.name}_storage_attached", storage=storage) @staticmethod + @_copy_doc(ops.StorageDetachingEvent) def storage_detaching(storage: Storage): return _Event(f"{storage.name}_storage_detaching", storage=storage) @staticmethod + @_copy_doc(ops.PebbleReadyEvent) def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) @staticmethod + @_copy_doc(ops.PebbleCustomNoticeEvent) def pebble_custom_notice(container: Container, notice: Notice): return _Event( f"{container.name}_pebble_custom_notice", @@ -263,6 +305,7 @@ def pebble_custom_notice(container: Container, notice: Notice): ) @staticmethod + @_copy_doc(ops.PebbleCheckFailedEvent) def pebble_check_failed(container: Container, info: CheckInfo): return _Event( f"{container.name}_pebble_check_failed", @@ -271,6 +314,7 @@ def pebble_check_failed(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.PebbleCheckRecoveredEvent) def pebble_check_recovered(container: Container, info: CheckInfo): return _Event( f"{container.name}_pebble_check_recovered", @@ -279,6 +323,7 @@ def pebble_check_recovered(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.ActionEvent) def action( name: str, params: Optional[Dict[str, "AnyJson"]] = None, @@ -295,13 +340,15 @@ def action( class Context: """Represents a simulated charm's execution context. - It is the main entry point to running a scenario test. + The main entry point to running a test. It contains: - It contains: the charm source code being executed, the metadata files associated with it, - a charm project repository root, and the Juju version to be simulated. + - the charm source code being executed + - the metadata files associated with it + - a charm project repository root + - the Juju version to be simulated - After you have instantiated ``Context``, typically you will call ``run()``to execute the charm - once, write any assertions you like on the output state returned by the call, write any + After you have instantiated ``Context``, typically you will call :meth:`run()` to execute the + charm once, write any assertions you like on the output state returned by the call, write any assertions you like on the ``Context`` attributes, then discard the ``Context``. Each ``Context`` instance is in principle designed to be single-use: @@ -310,63 +357,77 @@ class Context: Any side effects generated by executing the charm, that are not rightful part of the ``State``, are in fact stored in the ``Context``: - - :attr:`juju_log`: record of what the charm has sent to juju-log - - :attr:`app_status_history`: record of the app statuses the charm has set - - :attr:`unit_status_history`: record of the unit statuses the charm has set - - :attr:`workload_version_history`: record of the workload versions the charm has set - - :attr:`removed_secret_revisions`: record of the secret revisions the charm has removed - - :attr:`emitted_events`: record of the events (including custom) that the charm has processed - - :attr:`action_logs`: logs associated with the action output, set by the charm with - :meth:`ops.ActionEvent.log` - - :attr:`action_results`: key-value mapping assigned by the charm as a result of the action. - Will be None if the charm never calls :meth:`ops.ActionEvent.set_results` + - :attr:`juju_log` + - :attr:`app_status_history` + - :attr:`unit_status_history` + - :attr:`workload_version_history` + - :attr:`removed_secret_revisions` + - :attr:`requested_storages` + - :attr:`emitted_events` + - :attr:`action_logs` + - :attr:`action_results` This allows you to write assertions not only on the output state, but also, to some extent, on the path the charm took to get there. - A typical scenario test will look like:: + A typical test will look like:: - from scenario import Context, State - from ops import ActiveStatus from charm import MyCharm, MyCustomEvent # noqa def test_foo(): # Arrange: set the context up - c = Context(MyCharm) + ctx = Context(MyCharm) # Act: prepare the state and emit an event - state_out = c.run('update-status', State()) + state_out = ctx.run(ctx.on.update_status(), State()) # Assert: verify the output state is what you think it should be assert state_out.unit_status == ActiveStatus('foobar') # Assert: verify the Context contains what you think it should assert len(c.emitted_events) == 4 assert isinstance(c.emitted_events[3], MyCustomEvent) - If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the - execution cwd, you need to use the ``charm_root`` argument. For example:: - - import scenario - import tempfile - virtual_root = tempfile.TemporaryDirectory() - local_path = Path(local_path.name) - (local_path / 'foo').mkdir() - (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - scenario.Context(... charm_root=virtual_root).run(...) - If you need access to the charm object that will handle the event, use the class in a ``with`` statement, like:: - import scenario - def test_foo(): - ctx = scenario.Context(MyCharm) + ctx = Context(MyCharm) with ctx(ctx.on.start(), State()) as manager: manager.charm._some_private_setup() manager.run() """ + juju_log: List["JujuLogLine"] + """A record of what the charm has sent to juju-log""" + app_status_history: List["_EntityStatus"] + """A record of the app statuses the charm has set""" + unit_status_history: List["_EntityStatus"] + """A record of the unit statuses the charm has set""" + workload_version_history: List[str] + """A record of the workload versions the charm has set""" + removed_secret_revisions: List[int] + """A record of the secret revisions the charm has removed""" + emitted_events: List[ops.EventBase] + """A record of the events (including custom) that the charm has processed""" + requested_storages: Dict[str, int] + """A record of the storages the charm has requested""" + action_logs: List[str] + """The logs associated with the action output, set by the charm with :meth:`ops.ActionEvent.log` + + This will be empty when handling a non-action event. + """ + action_results: Optional[Dict[str, Any]] + """A key-value mapping assigned by the charm as a result of the action. + + This will be ``None`` if the charm never calls :meth:`ops.ActionEvent.set_results` + """ + on: CharmEvents + """The events that this charm can respond to. + + Use this when calling :meth:`run` to specify the event to emit. + """ + def __init__( self, - charm_type: Type["CharmType"], + charm_type: Type[ops.testing.CharmType], meta: Optional[Dict[str, Any]] = None, *, actions: Optional[Dict[str, Any]] = None, @@ -381,7 +442,17 @@ def __init__( ): """Represents a simulated charm's execution context. - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. + If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the + execution cwd, you need to use the ``charm_root`` argument. For example:: + + import tempfile + virtual_root = tempfile.TemporaryDirectory() + local_path = Path(local_path.name) + (local_path / 'foo').mkdir() + (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + Context(... charm_root=virtual_root).run(...) + + :arg charm_type: the :class:`ops.CharmBase` subclass to handle the event. :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). If none is provided, we will search for a ``metadata.yaml`` file in the charm root. :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict). @@ -390,11 +461,11 @@ def __init__( If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. :arg app_name: App name that this charm is deployed as. Defaults to the charm name as - defined in metadata.yaml. - :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. + defined in the metadata. + :arg unit_id: Unit ID that this charm is deployed as. :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with - ``juju trust``). Defaults to False. - :arg charm_root: virtual charm root the charm will be executed with. + ``juju trust``). + :arg charm_root: virtual charm filesystem root the charm will be executed with. """ if not any((meta, actions, config)): @@ -438,10 +509,10 @@ def __init__( self.juju_log: List["JujuLogLine"] = [] self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] - self.exec_history: Dict[str, List[ExecArgs]] = {} + self.exec_history: Dict[str, List[ops.testing.ExecArgs]] = {} self.workload_version_history: List[str] = [] self.removed_secret_revisions: List[int] = [] - self.emitted_events: List[EventBase] = [] + self.emitted_events: List[ops.EventBase] = [] self.requested_storages: Dict[str, int] = {} # set by Runtime.exec() in self._run() @@ -452,7 +523,7 @@ def __init__( self.action_results: Optional[Dict[str, Any]] = None self._action_failure_message: Optional[str] = None - self.on = _CharmEvents() + self.on = CharmEvents() def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" @@ -488,20 +559,21 @@ def __call__(self, event: "_Event", state: "State"): assert manager.charm._some_private_attribute == "bar" # noqa Args: - event: the :class:`Event` that the charm will respond to. - state: the :class:`State` instance to use when handling the Event. + event: the event that the charm will respond to. + state: the :class:`State` instance to use when handling the event. """ return Manager(self, event, state) def run(self, event: "_Event", state: "State") -> "State": - """Trigger a charm execution with an Event and a State. + """Trigger a charm execution with an event and a State. Calling this function will call ``ops.main`` and set up the context according to the - specified ``State``, then emit the event on the charm. + specified :class:`State`, then emit the event on the charm. - :arg event: the Event that the charm will respond to. - :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the Event. + :arg event: the event that the charm will respond to. Use the :attr:`on` attribute to + specify the event; for example: ``ctx.on.start()``. + :arg state: the :class:`State` instance to use as data source for the hook tool calls that + the charm will invoke when handling the event. """ if event.action: # Reset the logs, failure status, and results, in case the context diff --git a/scenario/runtime.py b/scenario/runtime.py index 754829c0..f4df73db 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -121,7 +121,7 @@ def apply_state(self, state: "State"): db.save_snapshot(event.handle_path, event.snapshot_data) for stored_state in state.stored_states: - db.save_snapshot(stored_state.handle_path, stored_state.content) + db.save_snapshot(stored_state._handle_path, stored_state.content) db.close() diff --git a/scenario/state.py b/scenario/state.py index 33d5f280..3a72f20b 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -10,7 +10,6 @@ import random import re import string -from collections import namedtuple from enum import Enum from itertools import chain from pathlib import Path, PurePosixPath @@ -48,8 +47,6 @@ from scenario.errors import MetadataNotFoundError, StateValidationError from scenario.logger import logger as scenario_logger -JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) - if TYPE_CHECKING: # pragma: no cover from scenario import Context @@ -116,7 +113,7 @@ class ActionFailed(Exception): - """Raised at the end of the hook if the charm has called `event.fail()`.""" + """Raised at the end of the hook if the charm has called ``event.fail()``.""" def __init__(self, message: str, state: "State"): self.message = message @@ -192,8 +189,20 @@ def __reduce__(self): return _MaxPositionalArgs +@dataclasses.dataclass(frozen=True) +class JujuLogLine(_max_posargs(2)): + """An entry in the Juju debug-log.""" + + level: str + """The level of the message, for example ``INFO`` or ``ERROR``.""" + message: str + """The log message.""" + + @dataclasses.dataclass(frozen=True) class CloudCredential(_max_posargs(0)): + __doc__ = ops.CloudCredential.__doc__ + auth_type: str """Authentication type.""" @@ -217,6 +226,8 @@ def _to_ops(self) -> CloudCredential_Ops: @dataclasses.dataclass(frozen=True) class CloudSpec(_max_posargs(1)): + __doc__ = ops.CloudSpec.__doc__ + type: str """Type of the cloud.""" @@ -273,23 +284,51 @@ def _generate_secret_id(): @dataclasses.dataclass(frozen=True) class Secret(_max_posargs(1)): + """A Juju secret. + + This class is used for both user and charm secrets. + """ + tracked_content: "RawSecretRevisionContents" + """The content of the secret that the charm is currently tracking. + + This is the content the charm will receive with a + :meth:`ops.Secret.get_content` call.""" latest_content: Optional["RawSecretRevisionContents"] = None + """The content of the latest revision of the secret. + + This is the content the charm will receive with a + :meth:`ops.Secret.peek_content` call.""" id: str = dataclasses.field(default_factory=_generate_secret_id) + """The Juju ID of the secret. + + This is automatically assigned and should not usually need to be explicitly set. + """ - # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. - # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None + """Indicates if the secret is owned by *this* unit, *this* application, or + another application/unit. + + If None, the implication is that read access to the secret has been granted + to this unit. + """ - # mapping from relation IDs to remote unit/apps to which this secret has been granted. - # Only applicable if owner remote_grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict) + """Mapping from relation IDs to remote units and applications to which this + secret has been granted.""" label: Optional[str] = None + """A human-readable label the charm can use to retrieve the secret. + + If this is set, it implies that the charm has previously set the label. + """ description: Optional[str] = None + """A human-readable description of the secret.""" expire: Optional[datetime.datetime] = None + """The time at which the secret will expire.""" rotate: Optional[SecretRotate] = None + """The rotation policy for the secret.""" # what revision is currently tracked by this charm. Only meaningful if owner=False _tracked_revision: int = 1 @@ -375,8 +414,11 @@ class BindAddress(_max_posargs(1)): interface_name: str addresses: List[Address] + """The addresses in the space.""" interface_name: str = "" + """The name of the network interface.""" mac_address: Optional[str] = None + """The MAC address of the interface.""" def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would @@ -392,16 +434,22 @@ def _hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) class Network(_max_posargs(2)): + """A Juju network space.""" + binding_name: str + """The name of the network space.""" bind_addresses: List[BindAddress] = dataclasses.field( default_factory=lambda: [BindAddress([Address("192.0.2.0")])], ) + """Addresses that the charm's application should bind to.""" ingress_addresses: List[str] = dataclasses.field( default_factory=lambda: ["192.0.2.0"], ) + """Addresses other applications should use to connect to the unit.""" egress_subnets: List[str] = dataclasses.field( default_factory=lambda: ["192.0.2.0/24"], ) + """Subnets that other units will see the charm connecting from.""" def __hash__(self) -> int: return hash(self.binding_name) @@ -421,6 +469,11 @@ def _hook_tool_output_fmt(self): def _next_relation_id(*, update=True): + """Get the ID the next relation to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -431,11 +484,11 @@ def _next_relation_id(*, update=True): @dataclasses.dataclass(frozen=True) class RelationBase(_max_posargs(2)): endpoint: str - """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" + """Relation endpoint name. Must match some endpoint name defined in the metadata.""" interface: Optional[str] = None - """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. - If left empty, it will be automatically derived from metadata.yaml.""" + """Interface name. Must match the interface name attached to this endpoint in the metadata. + If left empty, it will be automatically derived from the metadata.""" id: int = dataclasses.field(default_factory=_next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, @@ -546,14 +599,19 @@ def _databags(self): @dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): + """A relation to share data between a subordinate and a principal charm.""" + remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + """The current content of the remote application databag.""" remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) + """The current content of the remote unit databag.""" - # app name and ID of the remote unit that *this unit* is attached to. remote_app_name: str = "remote" + """The name of the remote application that *this unit* is attached to.""" remote_unit_id: int = 0 + """The ID of the remote unit that *this unit* is attached to.""" def __hash__(self) -> int: return hash(self.id) @@ -582,6 +640,7 @@ def _databags(self): @property def remote_unit_name(self) -> str: + """The full name of the remote unit, in the form ``remote/0``.""" return f"{self.remote_app_name}/{self.remote_unit_id}" @@ -713,6 +772,11 @@ def _now_utc(): def _next_notice_id(*, update=True): + """Get the ID the next Pebble notice to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -722,6 +786,8 @@ def _next_notice_id(*, update=True): @dataclasses.dataclass(frozen=True) class Notice(_max_posargs(1)): + """A Pebble notice.""" + key: str """The notice key, a string that differentiates notices of this type. @@ -781,6 +847,8 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) class CheckInfo(_max_posargs(1)): + """A health check for a Pebble workload container.""" + name: str """Name of the check.""" @@ -790,9 +858,10 @@ class CheckInfo(_max_posargs(1)): status: pebble.CheckStatus = pebble.CheckStatus.UP """Status of the check. - CheckStatus.UP means the check is healthy (the number of failures is less - than the threshold), CheckStatus.DOWN means the check is unhealthy - (the number of failures has reached the threshold). + :attr:`ops.pebble.CheckStatus.UP` means the check is healthy (the number of + failures is fewer than the threshold), :attr:`ops.pebble.CheckStatus.DOWN` + means the check is unhealthy (the number of failures has reached the + threshold). """ failures: int = 0 @@ -801,7 +870,7 @@ class CheckInfo(_max_posargs(1)): threshold: int = 3 """Failure threshold. - This is how many consecutive failures for the check to be considered “down”. + This is how many consecutive failures for the check to be considered 'down'. """ def _to_ops(self) -> pebble.CheckInfo: @@ -831,7 +900,7 @@ class Container(_max_posargs(1)): # will be unknown. all that we can know is the resulting plan (the 'computed plan'). _base_plan: dict = dataclasses.field(default_factory=dict) # We expect most of the user-facing testing to be covered by this 'layers' attribute, - # as all will be known when unit-testing. + # as it is all that will be known when unit-testing. layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) """All :class:`ops.pebble.Layer` definitions that have already been added to the container.""" @@ -866,8 +935,8 @@ class Container(_max_posargs(1)): this becomes:: mounts = { - 'foo': scenario.Mount('/home/foo', Path('/path/to/local/dir/containing/bar/py/')), - 'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')), + 'foo': Mount('/home/foo', pathlib.Path('/path/to/local/dir/containing/bar/py/')), + 'bin': Mount('/bin/', pathlib.Path('/path/to/local/dir/containing/bash/and/baz/')), } """ @@ -879,7 +948,7 @@ class Container(_max_posargs(1)): For example:: - container = scenario.Container( + container = Container( name='foo', execs={ scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'), @@ -893,8 +962,10 @@ class Container(_max_posargs(1)): """ notices: List[Notice] = dataclasses.field(default_factory=list) + """Any Pebble notices that already exist in the container.""" check_infos: FrozenSet[CheckInfo] = frozenset() + """All Pebble health checks that have been added to the container.""" def __hash__(self) -> int: return hash(self.name) @@ -917,9 +988,9 @@ def _render_services(self): def plan(self) -> pebble.Plan: """The 'computed' Pebble plan. - i.e. the base plan plus the layers that have been added on top. - You should run your assertions on this plan, not so much on the layers, as those are - input data. + This is the base plan plus the layers that have been added on top. + You should run your assertions on this plan, not so much on the layers, + as those are input data. """ # copied over from ops.testing._TestingPebbleClient.get_plan(). @@ -1087,26 +1158,40 @@ def __init__(self, message: str = ""): @dataclasses.dataclass(frozen=True) class StoredState(_max_posargs(1)): + """Represents unit-local state that persists across events.""" + name: str = "_stored" + """The attribute in the parent Object where the state is stored. + + For example, ``_stored`` in this class:: + + class MyCharm(ops.CharmBase): + _stored = ops.StoredState() + + """ - # /-separated Object names. E.g. MyCharm/MyCharmLib. - # if None, this StoredState instance is owned by the Framework. owner_path: Optional[str] = None + """The path to the owner of this StoredState instance. + + If None, the owner is the Framework. Otherwise, /-separated object names, + for example MyCharm/MyCharmLib. + """ # Ideally, the type here would be only marshallable types, rather than Any. # However, it's complex to describe those types, since it's a recursive # definition - even in TypeShed the _Marshallable type includes containers # like list[Any], which seems to defeat the point. content: Dict[str, Any] = dataclasses.field(default_factory=dict) + """The content of the :class:`ops.StoredState` instance.""" _data_type_name: str = "StoredStateData" @property - def handle_path(self): + def _handle_path(self): return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]" def __hash__(self) -> int: - return hash(self.handle_path) + return hash(self._handle_path) _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] @@ -1116,8 +1201,8 @@ def __hash__(self) -> int: class Port(_max_posargs(1)): """Represents a port on the charm host. - Port objects should not be instantiated directly: use TCPPort, UDPPort, or - ICMPPort instead. + Port objects should not be instantiated directly: use :class:`TCPPort`, + :class:`UDPPort`, or :class:`ICMPPort` instead. """ port: Optional[int] = None @@ -1146,6 +1231,10 @@ class TCPPort(Port): port: int """The port to open.""" protocol: _RawPortProtocolLiteral = "tcp" + """The protocol that data transferred over the port will use. + + :meta private: + """ def __post_init__(self): super().__post_init__() @@ -1162,6 +1251,10 @@ class UDPPort(Port): port: int """The port to open.""" protocol: _RawPortProtocolLiteral = "udp" + """The protocol that data transferred over the port will use. + + :meta private: + """ def __post_init__(self): super().__post_init__() @@ -1176,6 +1269,10 @@ class ICMPPort(Port): """Represents an ICMP port on the charm host.""" protocol: _RawPortProtocolLiteral = "icmp" + """The protocol that data transferred over the port will use. + + :meta private: + """ _max_positional_args: Final = 0 @@ -1210,12 +1307,16 @@ def _next_storage_index(*, update=True): @dataclasses.dataclass(frozen=True) class Storage(_max_posargs(1)): - """Represents an (attached!) storage made available to the charm container.""" + """Represents an (attached) storage made available to the charm container.""" name: str + """The name of the storage, as found in the charm metadata.""" index: int = dataclasses.field(default_factory=_next_storage_index) - # Every new Storage instance gets a new one, if there's trouble, override. + """The index of this storage instance. + + For Kubernetes charms, this will always be 1. For machine charms, each new + Storage instance gets a new index.""" def __eq__(self, other: object) -> bool: if isinstance(other, (Storage, ops.Storage)): @@ -1232,15 +1333,17 @@ class Resource(_max_posargs(0)): """Represents a resource made available to the charm.""" name: str + """The name of the resource, as found in the charm metadata.""" path: Union[str, Path] + """A local path that will be provided to the charm as the content of the resource.""" @dataclasses.dataclass(frozen=True) class State(_max_posargs(0)): - """Represents the juju-owned portion of a unit's state. + """Represents the Juju-owned portion of a unit's state. Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its - lifecycle. For example, status-get will return data from `State.status`, is-leader will + lifecycle. For example, status-get will return data from `State.unit_status`, is-leader will return data from `State.leader`, and so on. """ @@ -1254,31 +1357,36 @@ class State(_max_posargs(0)): """Manual overrides for any relation and extra bindings currently provisioned for this charm. If a metadata-defined relation endpoint is not explicitly mapped to a Network in this field, it will be defaulted. - [CAVEAT: `extra-bindings` is a deprecated, regretful feature in juju/ops. For completeness we - support it, but use at your own risk.] If a metadata-defined extra-binding is left empty, - it will be defaulted. + + .. warning:: + `extra-bindings` is a deprecated, regretful feature in Juju/ops. For completeness we + support it, but use at your own risk. If a metadata-defined extra-binding is left empty, + it will be defaulted. """ containers: Iterable[Container] = dataclasses.field(default_factory=frozenset) """All containers (whether they can connect or not) that this charm is aware of.""" storages: Iterable[Storage] = dataclasses.field(default_factory=frozenset) - """All ATTACHED storage instances for this charm. + """All **attached** storage instances for this charm. + If a storage is not attached, omit it from this listing.""" # we don't use sets to make json serialization easier opened_ports: Iterable[Port] = dataclasses.field(default_factory=frozenset) - """Ports opened by juju on this charm.""" + """Ports opened by Juju on this charm.""" leader: bool = False """Whether this charm has leadership.""" model: Model = Model() """The model this charm lives in.""" secrets: Iterable[Secret] = dataclasses.field(default_factory=frozenset) """The secrets this charm has access to (as an owner, or as a grantee). + The presence of a secret in this list entails that the charm can read it. Whether it can manage it or not depends on the individual secret's `owner` flag.""" resources: Iterable[Resource] = dataclasses.field(default_factory=frozenset) """All resources that this charm can access.""" planned_units: int = 1 """Number of non-dying planned units that are expected to be running this application. + Use with caution.""" # Represents the OF's event queue. These events will be emitted before the event being @@ -1561,8 +1669,8 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: class DeferredEvent: """An event that has been deferred to run prior to the next Juju event. - Tests should not instantiate this class directly: use :meth:`_Event.deferred` - instead. For example: + Tests should not instantiate this class directly: use the `deferred` method + of the event instead. For example: ctx = Context(MyCharm) deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) @@ -1883,6 +1991,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def _next_action_id(*, update=True): + """Get the ID the next action to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_action_id_counter cur = _next_action_id_counter if update: