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: