diff --git a/docs/capture_events.rst b/docs/capture_events.rst new file mode 100644 index 00000000..ff54eda9 --- /dev/null +++ b/docs/capture_events.rst @@ -0,0 +1,4 @@ +scenario.capture_events +======================= + +.. automodule:: scenario.capture_events diff --git a/docs/consistency_checker.rst b/docs/consistency_checker.rst new file mode 100644 index 00000000..92203a47 --- /dev/null +++ b/docs/consistency_checker.rst @@ -0,0 +1,4 @@ +scenario.consistency_checker +============================ + +.. automodule:: scenario.consistency_checker diff --git a/docs/context.rst b/docs/context.rst new file mode 100644 index 00000000..b51128b8 --- /dev/null +++ b/docs/context.rst @@ -0,0 +1,5 @@ +scenario.Context +================ + +.. automodule:: scenario.context + :members: DEFAULT_JUJU_VERSION, InvalidEventError, ContextSetupError, AlreadyEmittedError diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 10deb009..5462f6f5 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -306,10 +306,10 @@ def _compute_navigation_tree(context): # ('envvar', 'LD_LIBRARY_PATH'). nitpick_ignore = [ # Please keep this list sorted alphabetically. - ('py:class', 'AnyJson'), ('py:class', '_CharmSpec'), ('py:class', '_Event'), - ('py:class', 'scenario.state._DCBase'), + ('py:class', '_EntityStatus'), + ('py:class', 'ModelError'), ('py:class', 'scenario.state._EntityStatus'), ('py:class', 'scenario.state._Event'), ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), diff --git a/docs/index.rst b/docs/index.rst index 4d1af4d9..90344659 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,32 +1,21 @@ - Scenario API reference ====================== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: -scenario.State -============== - -.. automodule:: scenario.state - - -scenario.Context -================ - -.. automodule:: scenario.context - -scenario.consistency_checker -============================ - -.. automodule:: scenario.consistency_checker + context + state + capture_events + consistency_checker -scenario.capture_events -======================= +scenario +======== -.. automodule:: scenario.capture_events +.. automodule:: scenario + :special-members: __call__ Indices diff --git a/docs/requirements.txt b/docs/requirements.txt index 7b02bdf0..e8a95525 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -61,7 +61,7 @@ mdurl==0.1.2 # via markdown-it-py myst-parser==2.0.0 # via ops-scenario (pyproject.toml) -ops==2.12.0 +ops==2.15.0 # via ops-scenario (pyproject.toml) packaging==24.0 # via sphinx diff --git a/docs/state.rst b/docs/state.rst new file mode 100644 index 00000000..9f675ea9 --- /dev/null +++ b/docs/state.rst @@ -0,0 +1,5 @@ +scenario.State +============== + +.. automodule:: scenario.state + :members: ATTACH_ALL_STORAGES, CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, ACTION_EVENT_SUFFIX, BUILTIN_EVENTS, FRAMEWORK_EVENTS, PEBBLE_READY_EVENT_SUFFIX, PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, PEBBLE_CHECK_FAILED_EVENT_SUFFIX, PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, RELATION_EVENTS_SUFFIX, STORAGE_EVENTS_SUFFIX, SECRET_EVENTS, META_EVENTS, DEFAULT_JUJU_DATABAG, AnyJson, AnyRelation, CharmType, JujuLogLine, MetadataNotFoundError, PathLike, Port, RawDataBagContents, RawSecretRevisionContents, RelationBase, UnitID, next_action_id, next_notice_id, next_relation_id, next_storage_index diff --git a/scenario/__init__.py b/scenario/__init__.py index 24c1cac0..d1bf2bc5 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,6 +1,60 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +"""Charm state-transition testing SDK for Operator Framework charms. + +Write unit 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 somewhere in between unit and integration tests: they could be +called 'functional' or 'contract', and most properly are 'state-transition'. +However, 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 relation data? what is my +config?...) and the charm's execution `Context` (what relations 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 relations, 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, @@ -22,7 +76,6 @@ Network, Notice, PeerRelation, - Port, Relation, Resource, Secret, @@ -58,7 +111,6 @@ "Address", "BindAddress", "Network", - "Port", "ICMPPort", "TCPPort", "UDPPort", diff --git a/scenario/capture_events.py b/scenario/capture_events.py index 3b094797..1f00e01c 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -25,23 +25,23 @@ def capture_events( include_framework=False, include_deferred=True, ): - """Capture all events of type `*types` (using instance checks). - - Arguments exposed so that you can define your own fixtures if you want to. + """Capture all events of type ``*types`` (using instance checks). Example:: - >>> from ops.charm import StartEvent - >>> from scenario import Event, State - >>> from charm import MyCustomEvent, MyCharm # noqa - >>> - >>> def test_my_event(): - >>> with capture_events(StartEvent, MyCustomEvent) as captured: - >>> trigger(State(), ("start", MyCharm, meta=MyCharm.META) - >>> - >>> assert len(captured) == 2 - >>> e1, e2 = captured - >>> assert isinstance(e2, MyCustomEvent) - >>> assert e2.custom_attr == 'foo' + + from ops.charm import StartEvent + from scenario import Event, State + from charm import MyCustomEvent, MyCharm # noqa + + def test_my_event(): + ctx = Context(MyCharm, meta=MyCharm.META) + with capture_events(StartEvent, MyCustomEvent) as captured: + ctx.run(ctx.on.start(), State()) + + assert len(captured) == 2 + e1, e2 = captured + assert isinstance(e2, MyCustomEvent) + assert e2.custom_attr == 'foo' """ allowed_types = types or (EventBase,) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 1334bbbd..3710be3e 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 @@ -16,7 +33,7 @@ SubordinateRelation, _Action, _CharmSpec, - normalize_name, + _normalize_name, ) if TYPE_CHECKING: # pragma: no cover @@ -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.yaml``, so a :class:`scenario.State` declaring some config keys + that are not in the charm's ``config.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. @@ -170,7 +193,7 @@ def _check_relation_event( "Please pass one.", ) else: - if not event.name.startswith(normalize_name(event.relation.endpoint)): + if not event.name.startswith(_normalize_name(event.relation.endpoint)): errors.append( f"relation event should start with relation endpoint name. {event.name} does " f"not start with {event.relation.endpoint}.", @@ -193,7 +216,7 @@ def _check_workload_event( "cannot construct a workload event without the container instance. " "Please pass one.", ) - elif not event.name.startswith(normalize_name(event.container.name)): + elif not event.name.startswith(_normalize_name(event.container.name)): errors.append( f"workload event should start with container name. {event.name} does " f"not start with {event.container.name}.", @@ -225,7 +248,7 @@ def _check_action_event( ) return - elif not event.name.startswith(normalize_name(action.name)): + elif not event.name.startswith(_normalize_name(action.name)): errors.append( f"action event should start with action name. {event.name} does " f"not start with {action.name}.", @@ -255,7 +278,7 @@ def _check_storage_event( "cannot construct a storage event without the Storage instance. " "Please pass one.", ) - elif not event.name.startswith(normalize_name(storage.name)): + elif not event.name.startswith(_normalize_name(storage.name)): errors.append( f"storage event should start with storage name. {event.name} does " f"not start with {storage.name}.", @@ -329,7 +352,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 = [] @@ -367,7 +390,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 = [] @@ -375,7 +398,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 @@ -425,7 +449,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, []) @@ -452,6 +476,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", ())) @@ -465,7 +490,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} @@ -484,6 +509,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() @@ -553,12 +579,12 @@ 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 - meta_containers = list(map(normalize_name, meta.get("containers", {}))) - state_containers = [normalize_name(c.name) for c in state.containers] + meta_containers = list(map(_normalize_name, meta.get("containers", {}))) + state_containers = [_normalize_name(c.name) for c in state.containers] all_notices = {notice.id for c in state.containers for notice in c.notices} all_checks = { (c.name, check.name) for c in state.containers for check in c.check_infos @@ -614,7 +640,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 = [] @@ -622,8 +648,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) @@ -634,7 +659,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 7998ceb4..d0861f3c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,12 +1,14 @@ #!/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 +import ops from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime @@ -37,19 +39,15 @@ class InvalidEventError(RuntimeError): - """raised when something is wrong with the event passed to Context.run""" - - -class InvalidActionError(InvalidEventError): - """raised when something is wrong with an action passed to Context.run""" + """Raised when something is wrong with the event passed to :meth:`scenario.Context.run`""" class ContextSetupError(RuntimeError): - """Raised by Context when setup fails.""" + """Raised by :class:`scenario.Context` when setup fails.""" class AlreadyEmittedError(RuntimeError): - """Raised when ``run()`` is called more than once.""" + """Raised when :meth:`scenario.Manager.run()` is called more than once.""" class Manager: @@ -80,12 +78,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): @@ -120,63 +122,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) - By default, the events listed as attributes of this class will be - provided via the :attr:`Context.on` attribute. For example:: + wrapped.__doc__ = original_func.__doc__ + return wrapped + + 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( @@ -185,6 +211,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( @@ -193,6 +220,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( @@ -201,6 +229,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( @@ -210,17 +239,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: "AnyRelation"): return _Event(f"{relation.endpoint}_relation_created", relation=relation) @staticmethod + @_copy_doc(ops.RelationJoinedEvent) def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_joined", @@ -229,6 +262,7 @@ def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = Non ) @staticmethod + @_copy_doc(ops.RelationChangedEvent) def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_changed", @@ -237,6 +271,7 @@ def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = No ) @staticmethod + @_copy_doc(ops.RelationDepartedEvent) def relation_departed( relation: "AnyRelation", *, @@ -251,22 +286,27 @@ def relation_departed( ) @staticmethod + @_copy_doc(ops.RelationBrokenEvent) def relation_broken(relation: "AnyRelation"): 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", @@ -275,6 +315,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", @@ -283,6 +324,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", @@ -291,6 +333,7 @@ def pebble_check_recovered(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.ActionEvent) def action( name: str, params: Optional[Dict[str, "AnyJson"]] = None, @@ -307,13 +350,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: @@ -322,60 +367,74 @@ 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"], @@ -393,7 +452,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). @@ -402,11 +471,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)): @@ -452,7 +521,7 @@ def __init__( self.unit_status_history: List["_EntityStatus"] = [] 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() @@ -463,7 +532,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.""" @@ -512,20 +581,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/mocking.py b/scenario/mocking.py index cf0480bf..0caf4fa1 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -312,7 +312,7 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): network = self._state.get_network(binding_name) except KeyError: network = Network("default") # The name is not used in the output. - return network.hook_tool_output_fmt() + return network._hook_tool_output_fmt() # setter methods: these can mutate the state. def application_version_set(self, version: str): diff --git a/scenario/state.py b/scenario/state.py index 789ddbfc..ab5c6100 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -8,7 +8,6 @@ import random import re import string -from collections import namedtuple from enum import Enum from itertools import chain from pathlib import Path, PurePosixPath @@ -44,8 +43,6 @@ from scenario.logger import logger as scenario_logger -JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) - if TYPE_CHECKING: # pragma: no cover from scenario import Context @@ -128,11 +125,11 @@ class StateValidationError(RuntimeError): class MetadataNotFoundError(RuntimeError): - """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" + """Raised when Scenario can't find a metadata YAML file in the provided charm root.""" 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 @@ -208,8 +205,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.""" @@ -233,6 +242,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.""" @@ -289,23 +300,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 @@ -354,7 +393,7 @@ def _update_metadata( object.__setattr__(self, "rotate", rotate) -def normalize_name(s: str): +def _normalize_name(s: str): """Event names, in Scenario, uniformly use underscores instead of dashes.""" return s.replace("-", "_") @@ -385,10 +424,13 @@ class BindAddress(_max_posargs(1)): """An address bound to a network interface in a Juju space.""" 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): + def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would # todo support for legacy (deprecated) `interfacename` and `macaddress` fields? dct = { @@ -402,24 +444,32 @@ 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) - def hook_tool_output_fmt(self): + def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would return { - "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], + "bind-addresses": [ + ba._hook_tool_output_fmt() for ba in self.bind_addresses + ], "egress-subnets": self.egress_subnets, "ingress-addresses": self.ingress_addresses, } @@ -439,11 +489,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, @@ -715,6 +765,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. @@ -774,6 +826,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.""" @@ -783,9 +837,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 @@ -794,7 +849,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: @@ -824,7 +879,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.""" @@ -848,8 +903,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/')), } """ @@ -861,19 +916,21 @@ class Container(_max_posargs(1)): For example:: - container = scenario.Container( + container = Container( name='foo', exec_mock={ - ('whoami', ): scenario.ExecOutput(return_code=0, stdout='ubuntu') + ('whoami', ): ExecOutput(return_code=0, stdout='ubuntu') ('dig', '+short', 'canonical.com'): - scenario.ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21') + ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21') } ) """ 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) @@ -891,9 +948,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(). @@ -1090,8 +1147,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 @@ -1208,7 +1265,9 @@ 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: "PathLike" + """A local path that will be provided to the charm as the content of the resource.""" @dataclasses.dataclass(frozen=True) @@ -1447,11 +1506,11 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: # foo-bar: ... # foo_bar: ... - normalized_endpoint = normalize_name(endpoint) + normalized_endpoint = _normalize_name(endpoint) return tuple( r for r in self.relations - if normalize_name(r.endpoint) == normalized_endpoint + if _normalize_name(r.endpoint) == normalized_endpoint ) @@ -1591,7 +1650,7 @@ class _EventPath(str): type: _EventType def __new__(cls, string): - string = normalize_name(string) + string = _normalize_name(string) instance = super().__new__(cls, string) instance.name = name = string.split(".")[-1] diff --git a/tox.ini b/tox.ini index 1652385f..23703959 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ commands = description = Static typing checks. skip_install = true deps = - ops + ops >= 2.15 pyright==1.1.347 commands = pyright scenario