From 82778621a8994bcbbc1d63d5206a6dcba48edcd2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 11:55:41 +1300 Subject: [PATCH 01/35] Remove Context.clear() and Context.cleanup(). --- scenario/context.py | 27 --------------------------- tests/test_context.py | 11 ----------- 2 files changed, 38 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 6e07576c..3acc6bff 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -395,33 +395,6 @@ def _get_storage_root(self, name: str, index: int) -> Path: storage_root.mkdir(parents=True, exist_ok=True) return storage_root - def clear(self): - """Deprecated. - - Use cleanup instead. - """ - logger.warning( - "Context.clear() is deprecated and will be nuked in v6. " - "Use Context.cleanup() instead.", - ) - self.cleanup() - - def cleanup(self): - """Cleanup side effects histories and reset the simulated filesystem state.""" - self.juju_log = [] - self.app_status_history = [] - self.unit_status_history = [] - self.workload_version_history = [] - self.emitted_events = [] - self.requested_storages = {} - self._action_logs = [] - self._action_results = None - self._action_failure = None - self._output_state = None - - self._tmp.cleanup() - self._tmp = tempfile.TemporaryDirectory() - def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: diff --git a/tests/test_context.py b/tests/test_context.py index ff9ef690..7d2b795c 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -51,17 +51,6 @@ def test_run_action(): assert a.id == expected_id -def test_clear(): - ctx = Context(MyCharm, meta={"name": "foo"}) - state = State() - - ctx.run("start", state) - assert ctx.emitted_events - - ctx.clear() - assert not ctx.emitted_events # and others... - - @pytest.mark.parametrize("app_name", ("foo", "bar", "george")) @pytest.mark.parametrize("unit_id", (1, 2, 42)) def test_app_name(app_name, unit_id): From 57766b433a0b6e3aa2281d8839bb19293fb637a2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 21:44:13 +1300 Subject: [PATCH 02/35] Remove jsonpatch_delta from the public API. --- scenario/state.py | 21 --------------------- tests/helpers.py | 17 ++++++++++++++++- tests/test_e2e/test_pebble.py | 4 ++-- tests/test_e2e/test_play_assertions.py | 4 ++-- tests/test_e2e/test_state.py | 8 ++++---- 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index d8be99cb..10230be3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1255,23 +1255,6 @@ def get_storages(self, name: str) -> Tuple["Storage", ...]: """Get all storages with this name.""" return tuple(s for s in self.storage if s.name == name) - # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way. - def jsonpatch_delta(self, other: "State"): - try: - import jsonpatch # type: ignore - except ModuleNotFoundError: - logger.error( - "cannot import jsonpatch: using the .delta() " - "extension requires jsonpatch to be installed." - "Fetch it with pip install jsonpatch.", - ) - return NotImplemented - patch = jsonpatch.make_patch( - dataclasses.asdict(other), - dataclasses.asdict(self), - ).patch - return sort_patch(patch) - def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): # Check whether this dict has the expected mandatory metadata fields according to the @@ -1369,10 +1352,6 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: ) -def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): - return sorted(patch, key=key) - - @dataclasses.dataclass(frozen=True) class DeferredEvent(_DCBase): """An event that has been deferred to run prior to the next Juju event. diff --git a/tests/helpers.py b/tests/helpers.py index a8b2f551..7558e78d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,3 +1,4 @@ +import dataclasses import logging from pathlib import Path from typing import ( @@ -5,13 +6,15 @@ Any, Callable, Dict, + List, Optional, - Sequence, Type, TypeVar, Union, ) +import jsonpatch + from scenario.context import DEFAULT_JUJU_VERSION, Context if TYPE_CHECKING: # pragma: no cover @@ -52,3 +55,15 @@ def trigger( pre_event=pre_event, post_event=post_event, ) + + +def jsonpatch_delta(input: "State", output: "State"): + patch = jsonpatch.make_patch( + dataclasses.asdict(output), + dataclasses.asdict(input), + ).patch + return sort_patch(patch) + + +def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): + return sorted(patch, key=key) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index e5c16bf7..aa8b25d5 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -10,7 +10,7 @@ from scenario import Context from scenario.state import Container, ExecOutput, Mount, Notice, Port, State -from tests.helpers import trigger +from tests.helpers import jsonpatch_delta, trigger @pytest.fixture(scope="function") @@ -159,7 +159,7 @@ def callback(self: CharmBase): else: # nothing has changed out_purged = out.replace(stored_state=state.stored_state) - assert not out_purged.jsonpatch_delta(state) + assert not jsonpatch_delta(out_purged, state) LS = """ diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index b8b92d5a..4a1829f3 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -4,7 +4,7 @@ from ops.model import ActiveStatus, BlockedStatus from scenario.state import Relation, State -from tests.helpers import trigger +from tests.helpers import jsonpatch_delta, trigger @pytest.fixture(scope="function") @@ -61,7 +61,7 @@ def post_event(charm): assert out.unit_status == ActiveStatus("yabadoodle") out_purged = out.replace(stored_state=initial_state.stored_state) - assert out_purged.jsonpatch_delta(initial_state) == [ + assert jsonpatch_delta(out_purged, initial_state) == [ { "op": "replace", "path": "/unit_status/message", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 838426ae..06ce0728 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,8 +6,8 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State, sort_patch -from tests.helpers import trigger +from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State +from tests.helpers import jsonpatch_delta, sort_patch, trigger CUSTOM_EVT_SUFFIXES = { "relation_created", @@ -58,7 +58,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) out_purged = out.replace(stored_state=state.stored_state) - assert state.jsonpatch_delta(out_purged) == [] + assert jsonpatch_delta(state, out_purged) == [] def test_leader_get(state, mycharm): @@ -97,7 +97,7 @@ def call(charm: CharmBase, e): # ignore stored state in the delta out_purged = out.replace(stored_state=state.stored_state) - assert out_purged.jsonpatch_delta(state) == sort_patch( + assert jsonpatch_delta(out_purged, state) == sort_patch( [ {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, {"op": "replace", "path": "/app_status/name", "value": "waiting"}, From c64301fdaec84a26308a3201abfeb2e61fc9c58d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 22:21:12 +1300 Subject: [PATCH 03/35] Remove _DCBase. --- README.md | 15 +++--- scenario/runtime.py | 9 ++-- scenario/sequences.py | 33 ++++++------ scenario/state.py | 70 ++++++++++---------------- tests/test_consistency_checker.py | 6 ++- tests/test_dcbase.py | 41 --------------- tests/test_e2e/test_pebble.py | 3 +- tests/test_e2e/test_play_assertions.py | 4 +- tests/test_e2e/test_state.py | 9 ++-- 9 files changed, 75 insertions(+), 115 deletions(-) delete mode 100644 tests/test_dcbase.py diff --git a/README.md b/README.md index 6007079a..2cc8f863 100644 --- a/README.md +++ b/README.md @@ -485,10 +485,12 @@ assert rel.relation_id == next_id This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: ```python -import scenario.state +import dataclasses +from scenario import Relation +from scenario.state import next_relation_id -rel = scenario.Relation('foo') -rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=scenario.state.next_relation_id()) +rel = Relation('foo') +rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, relation_id=next_relation_id()) assert rel2.relation_id == rel.relation_id + 1 ``` @@ -1356,14 +1358,15 @@ state that you obtain in return is a different instance, and all parts of it hav This ensures that you can do delta-based comparison of states without worrying about them being mutated by Scenario. If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use -the `replace` api. +the dataclasses `replace` api. ```python -import scenario +import dataclasses +from scenario import Relation relation = scenario.Relation('foo', remote_app_data={"1": "2"}) # make a copy of relation, but with remote_app_data set to {"3", "4"} -relation2 = relation.replace(remote_app_data={"3", "4"}) +relation2 = dataclasses.replace(relation, remote_app_data={"3", "4"}) ``` # Consistency checks diff --git a/scenario/runtime.py b/scenario/runtime.py index 71b109a5..4395c6dd 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import copy +import dataclasses import marshal import os import re @@ -379,7 +381,7 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): store = self._get_state_db(temporary_charm_root) deferred = store.get_deferred_events() stored_state = store.get_stored_state() - return state.replace(deferred=deferred, stored_state=stored_state) + return dataclasses.replace(state, deferred=deferred, stored_state=stored_state) @contextmanager def _exec_ctx(self, ctx: "Context"): @@ -418,7 +420,7 @@ def exec( logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}") # we make a copy to avoid mutating the input state - output_state = state.copy() + output_state = copy.deepcopy(state) logger.info(" - generating virtual charm root") with self._exec_ctx(context) as (temporary_charm_root, captured): @@ -441,7 +443,8 @@ def exec( state=output_state, event=event, context=context, - charm_spec=self._charm_spec.replace( + charm_spec=dataclasses.replace( + self._charm_spec, charm_type=self._wrap(charm_type), ), ) diff --git a/scenario/sequences.py b/scenario/sequences.py index 49c01b33..83271e45 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import copy +import dataclasses import typing from itertools import chain from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union @@ -36,21 +38,24 @@ def decompose_meta_event(meta_event: Event, state: State): for relation in state.relations: event = relation.broken_event logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, state.copy() + yield event, copy.deepcopy(state) elif is_rel_created_meta_event: for relation in state.relations: event = relation.created_event logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, state.copy() + yield event, copy.deepcopy(state) else: raise RuntimeError(f"unknown meta-event {meta_event.name}") def generate_startup_sequence(state_template: State): yield from chain( - decompose_meta_event(Event(ATTACH_ALL_STORAGES), state_template.copy()), - ((Event("start"), state_template.copy()),), - decompose_meta_event(Event(CREATE_ALL_RELATIONS), state_template.copy()), + decompose_meta_event(Event(ATTACH_ALL_STORAGES), copy.deepcopy(state_template)), + ((Event("start"), copy.deepcopy(state_template)),), + decompose_meta_event( + Event(CREATE_ALL_RELATIONS), + copy.deepcopy(state_template), + ), ( ( Event( @@ -60,21 +65,21 @@ def generate_startup_sequence(state_template: State): else "leader_settings_changed" ), ), - state_template.copy(), + copy.deepcopy(state_template), ), - (Event("config_changed"), state_template.copy()), - (Event("install"), state_template.copy()), + (Event("config_changed"), copy.deepcopy(state_template)), + (Event("install"), copy.deepcopy(state_template)), ), ) def generate_teardown_sequence(state_template: State): yield from chain( - decompose_meta_event(Event(BREAK_ALL_RELATIONS), state_template.copy()), - decompose_meta_event(Event(DETACH_ALL_STORAGES), state_template.copy()), + decompose_meta_event(Event(BREAK_ALL_RELATIONS), copy.deepcopy(state_template)), + decompose_meta_event(Event(DETACH_ALL_STORAGES), copy.deepcopy(state_template)), ( - (Event("stop"), state_template.copy()), - (Event("remove"), state_template.copy()), + (Event("stop"), copy.deepcopy(state_template)), + (Event("remove"), copy.deepcopy(state_template)), ), ) @@ -112,8 +117,8 @@ def check_builtin_sequences( for event, state in generate_builtin_sequences( ( - template.replace(leader=True), - template.replace(leader=False), + dataclasses.replace(template, leader=True), + dataclasses.replace(template, leader=False), ), ): ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) diff --git a/scenario/state.py b/scenario/state.py index 10230be3..2f77f48c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -4,7 +4,6 @@ """The core Scenario State object, and the components inside it.""" -import copy import dataclasses import datetime import inspect @@ -43,11 +42,6 @@ JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) if TYPE_CHECKING: # pragma: no cover - try: - from typing import Self # type: ignore - except ImportError: - from typing_extensions import Self - from scenario import Context PathLike = Union[str, Path] @@ -134,17 +128,6 @@ class BindFailedError(RuntimeError): """Raised when Event.bind fails.""" -@dataclasses.dataclass(frozen=True) -class _DCBase: - def replace(self, *args, **kwargs): - """Produce a deep copy of this class, with some arguments replaced with new ones.""" - return dataclasses.replace(self.copy(), *args, **kwargs) - - def copy(self) -> "Self": - """Produce a deep copy of this object.""" - return copy.deepcopy(self) - - @dataclasses.dataclass(frozen=True) class CloudCredential: auth_type: str @@ -216,7 +199,7 @@ def _to_ops(self) -> ops.CloudSpec: @dataclasses.dataclass(frozen=True) -class Secret(_DCBase): +class Secret: id: str # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized # secret id (`secret:` prefix) @@ -342,7 +325,7 @@ def normalize_name(s: str): @dataclasses.dataclass(frozen=True) -class Address(_DCBase): +class Address: """An address in a Juju network space.""" hostname: str @@ -363,7 +346,7 @@ def address(self, value): @dataclasses.dataclass(frozen=True) -class BindAddress(_DCBase): +class BindAddress: """An address bound to a network interface in a Juju space.""" interface_name: str @@ -383,7 +366,7 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network(_DCBase): +class Network: bind_addresses: List[BindAddress] ingress_addresses: List[str] egress_subnets: List[str] @@ -435,7 +418,7 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) -class RelationBase(_DCBase): +class RelationBase: endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -659,7 +642,7 @@ def _random_model_name(): @dataclasses.dataclass(frozen=True) -class Model(_DCBase): +class Model: """The Juju model in which the charm is deployed.""" name: str = dataclasses.field(default_factory=_random_model_name) @@ -715,7 +698,7 @@ def _run(self) -> int: @dataclasses.dataclass(frozen=True) -class Mount(_DCBase): +class Mount: """Maps local files to a :class:`Container` filesystem.""" location: Union[str, PurePosixPath] @@ -815,7 +798,7 @@ def event(self): @dataclasses.dataclass(frozen=True) -class Container(_DCBase): +class Container: """A Kubernetes container where a charm's workload runs.""" name: str @@ -981,7 +964,7 @@ def get_notice( @dataclasses.dataclass(frozen=True) -class _EntityStatus(_DCBase): +class _EntityStatus: """This class represents StatusBase and should not be interacted with directly.""" # Why not use StatusBase directly? Because that's not json-serializable. @@ -1023,7 +1006,7 @@ class _MyClass(_EntityStatus, statusbase_subclass): @dataclasses.dataclass(frozen=True) -class StoredState(_DCBase): +class StoredState: # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. owner_path: Optional[str] @@ -1042,7 +1025,7 @@ def handle_path(self): @dataclasses.dataclass(frozen=True) -class Port(_DCBase): +class Port: """Represents a port on the charm host.""" protocol: _RawPortProtocolLiteral @@ -1085,7 +1068,7 @@ def next_storage_index(update=True): @dataclasses.dataclass(frozen=True) -class Storage(_DCBase): +class Storage: """Represents an (attached!) storage made available to the charm container.""" name: str @@ -1115,7 +1098,7 @@ def detaching_event(self) -> "Event": @dataclasses.dataclass(frozen=True) -class State(_DCBase): +class 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 @@ -1209,17 +1192,18 @@ def _update_status( def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: - return container.replace(can_connect=can_connect) + return dataclasses.replace(container, can_connect=can_connect) return container ctrs = tuple(map(replacer, self.containers)) - return self.replace(containers=ctrs) + return dataclasses.replace(self, containers=ctrs) def with_leadership(self, leader: bool) -> "State": - return self.replace(leader=leader) + return dataclasses.replace(self, leader=leader) def with_unit_status(self, status: StatusBase) -> "State": - return self.replace( + return dataclasses.replace( + self, status=dataclasses.replace( cast(_EntityStatus, self.unit_status), unit=_status_to_entitystatus(status), @@ -1271,7 +1255,7 @@ def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): @dataclasses.dataclass(frozen=True) -class _CharmSpec(_DCBase, Generic[CharmType]): +class _CharmSpec(Generic[CharmType]): """Charm spec.""" charm_type: Type[CharmBase] @@ -1353,7 +1337,7 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: @dataclasses.dataclass(frozen=True) -class DeferredEvent(_DCBase): +class DeferredEvent: """An event that has been deferred to run prior to the next Juju event. In most cases, the :func:`deferred` function should be used to create a @@ -1442,7 +1426,7 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: @dataclasses.dataclass(frozen=True) -class Event(_DCBase): +class Event: """A Juju, ops, or custom event that can be run against a charm. Typically, for simple events, the string name (e.g. ``install``) can be used, @@ -1486,7 +1470,7 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": "cannot pass param `remote_unit_id` to a " "non-relation event constructor.", ) - return self.replace(relation_remote_unit_id=remote_unit_id) + return dataclasses.replace(self, relation_remote_unit_id=remote_unit_id) def __post_init__(self): path = _EventPath(self.path) @@ -1582,7 +1566,7 @@ def bind(self, state: State): container = state.get_container(entity_name) except ValueError: raise BindFailedError(f"no container found with name {entity_name}") - return self.replace(container=container) + return dataclasses.replace(self, container=container) if self._is_secret_event and not self.secret: if len(state.secrets) < 1: @@ -1591,7 +1575,7 @@ def bind(self, state: State): raise BindFailedError( f"too many secrets found in state: cannot automatically bind {self}", ) - return self.replace(secret=state.secrets[0]) + return dataclasses.replace(self, secret=state.secrets[0]) if self._is_storage_event and not self.storage: storages = state.get_storages(entity_name) @@ -1604,7 +1588,7 @@ def bind(self, state: State): f"too many storages called {entity_name}: binding to first one", ) storage = storages[0] - return self.replace(storage=storage) + return dataclasses.replace(self, storage=storage) if self._is_relation_event and not self.relation: ep_name = entity_name @@ -1613,7 +1597,7 @@ def bind(self, state: State): raise BindFailedError(f"no relations on {ep_name} found in state") if len(relations) > 1: logger.warning(f"too many relations on {ep_name}: binding to first one") - return self.replace(relation=relations[0]) + return dataclasses.replace(self, relation=relations[0]) if self._is_action_event and not self.action: raise BindFailedError( @@ -1702,7 +1686,7 @@ def next_action_id(update=True): @dataclasses.dataclass(frozen=True) -class Action(_DCBase): +class Action: """A ``juju run`` command. Used to simulate ``juju run``, passing in any parameters. For example:: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 707cc7f4..a5ecdb73 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,3 +1,5 @@ +import dataclasses + import pytest from ops.charm import CharmBase @@ -490,14 +492,14 @@ def test_storage_states(): _CharmSpec(MyCharm, meta={"name": "everett"}), ) assert_consistent( - State(storage=[storage1, storage2.replace(index=2)]), + State(storage=[storage1, dataclasses.replace(storage2, index=2)]), Event("start"), _CharmSpec( MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} ), ) assert_consistent( - State(storage=[storage1, storage2.replace(name="marx")]), + State(storage=[storage1, dataclasses.replace(storage2, name="marx")]), Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_dcbase.py b/tests/test_dcbase.py deleted file mode 100644 index fd5ff872..00000000 --- a/tests/test_dcbase.py +++ /dev/null @@ -1,41 +0,0 @@ -import dataclasses -from typing import Dict, List - -from scenario.state import _DCBase - - -@dataclasses.dataclass(frozen=True) -class Foo(_DCBase): - a: int - b: List[int] - c: Dict[int, List[int]] - - -def test_base_case(): - l = [1, 2] - l1 = [1, 2, 3] - d = {1: l1} - f = Foo(1, l, d) - g = f.replace(a=2) - - assert g.a == 2 - assert g.b == l - assert g.c == d - assert g.c[1] == l1 - - -def test_dedup_on_replace(): - l = [1, 2] - l1 = [1, 2, 3] - d = {1: l1} - f = Foo(1, l, d) - g = f.replace(a=2) - - l.append(3) - l1.append(4) - d[2] = "foobar" - - assert g.a == 2 - assert g.b == [1, 2] - assert g.c == {1: [1, 2, 3]} - assert g.c[1] == [1, 2, 3] diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index aa8b25d5..f6143252 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,4 +1,5 @@ import datetime +import dataclasses import tempfile from pathlib import Path @@ -158,7 +159,7 @@ def callback(self: CharmBase): else: # nothing has changed - out_purged = out.replace(stored_state=state.stored_state) + out_purged = dataclasses.replace(out, stored_state=state.stored_state) assert not jsonpatch_delta(out_purged, state) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 4a1829f3..a5166db7 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -1,3 +1,5 @@ +import dataclasses + import pytest from ops.charm import CharmBase from ops.framework import Framework @@ -60,7 +62,7 @@ def post_event(charm): assert out.unit_status == ActiveStatus("yabadoodle") - out_purged = out.replace(stored_state=initial_state.stored_state) + out_purged = dataclasses.replace(out, stored_state=initial_state.stored_state) assert jsonpatch_delta(out_purged, initial_state) == [ { "op": "replace", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 06ce0728..477440d6 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,4 +1,4 @@ -from dataclasses import asdict +from dataclasses import asdict, replace from typing import Type import pytest @@ -57,7 +57,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) - out_purged = out.replace(stored_state=state.stored_state) + out_purged = replace(out, stored_state=state.stored_state) assert jsonpatch_delta(state, out_purged) == [] @@ -96,7 +96,7 @@ def call(charm: CharmBase, e): assert out.workload_version == "" # ignore stored state in the delta - out_purged = out.replace(stored_state=state.stored_state) + out_purged = replace(out, stored_state=state.stored_state) assert jsonpatch_delta(out_purged, state) == sort_patch( [ {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, @@ -224,7 +224,8 @@ def pre_event(charm: CharmBase): assert mycharm.called assert asdict(out.relations[0]) == asdict( - relation.replace( + replace( + relation, local_app_data={"a": "b"}, local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, ) From ee866ddddf002872abcb0b6e099414fc88a16af6 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 13:52:36 +1300 Subject: [PATCH 04/35] Drop tests for unsupported Python, add 3.12. --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 30f40fca..273c8e29 100644 --- a/tox.ini +++ b/tox.ini @@ -2,10 +2,9 @@ requires = tox>=4.2 env_list = + py312 py311 py38 - py37 - py36 unit lint lint-tests From 2e9adf0dcf54a247a52682be86a7e047b398f466 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 18 Apr 2024 21:07:22 +1200 Subject: [PATCH 05/35] Remove deprecated functionality. --- scenario/context.py | 50 +----------------------- scenario/sequences.py | 14 +++---- scenario/state.py | 31 --------------- scenario/strategies.py | 0 tests/helpers.py | 13 ++++--- tests/test_e2e/test_actions.py | 17 --------- tests/test_e2e/test_manager.py | 13 ------- tests/test_e2e/test_pebble.py | 70 +++++++++++++++++----------------- tests/test_e2e/test_ports.py | 25 +++++++----- tests/test_e2e/test_secrets.py | 46 ++-------------------- tests/test_e2e/test_state.py | 2 - tests/test_e2e/test_status.py | 68 +++++++++++++++++++-------------- tests/test_e2e/test_vroot.py | 2 +- tests/test_hypothesis.py | 0 14 files changed, 110 insertions(+), 241 deletions(-) delete mode 100644 scenario/strategies.py delete mode 100644 tests/test_hypothesis.py diff --git a/scenario/context.py b/scenario/context.py index 3acc6bff..25eb9f66 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -5,7 +5,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast from ops import CharmBase, EventBase @@ -430,20 +430,6 @@ def _coalesce_event(event: Union[str, Event]) -> Event: ) return event - @staticmethod - def _warn_deprecation_if_pre_or_post_event( - pre_event: Optional[Callable], - post_event: Optional[Callable], - ): - # warn if pre/post event arguments are passed - legacy_mode = pre_event or post_event - if legacy_mode: - logger.warning( - "The [pre/post]_event syntax is deprecated and " - "will be removed in a future release. " - "Please use the ``Context.[action_]manager`` context manager.", - ) - def manager( self, event: Union["Event", str], @@ -497,8 +483,6 @@ def run( self, event: Union["Event", str], state: "State", - pre_event: Optional[Callable[[CharmBase], None]] = None, - post_event: Optional[Callable[[CharmBase], None]] = None, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -508,32 +492,15 @@ def run( :arg event: the Event that the charm will respond to. Can be a string or an Event instance. :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 pre_event: callback to be invoked right before emitting the event on the newly - instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.manager`` instead. - :arg post_event: callback to be invoked right after emitting the event on the charm. - Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.manager`` instead. """ - self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) - with self._run_event(event=event, state=state) as ops: - if pre_event: - pre_event(cast(CharmBase, ops.charm)) - ops.emit() - - if post_event: - post_event(cast(CharmBase, ops.charm)) - return self.output_state def run_action( self, action: Union["Action", str], state: "State", - pre_event: Optional[Callable[[CharmBase], None]] = None, - post_event: Optional[Callable[[CharmBase], None]] = None, ) -> ActionOutput: """Trigger a charm execution with an Action and a State. @@ -543,25 +510,10 @@ def run_action( :arg action: the Action that the charm will execute. Can be a string or an Action instance. :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Action (event). - :arg pre_event: callback to be invoked right before emitting the event on the newly - instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.action_manager`` instead. - :arg post_event: callback to be invoked right after emitting the event on the charm. - Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.action_manager`` instead. """ - self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) - _action = self._coalesce_action(action) with self._run_action(action=_action, state=state) as ops: - if pre_event: - pre_event(cast(CharmBase, ops.charm)) - ops.emit() - - if post_event: - post_event(cast(CharmBase, ops.charm)) - return self._finalize_action(self.output_state) def _finalize_action(self, state_out: "State"): diff --git a/scenario/sequences.py b/scenario/sequences.py index 83271e45..2e1cbb22 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -122,12 +122,10 @@ def check_builtin_sequences( ), ): ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) - out.append( - ctx.run( - event, - state=state, - pre_event=pre_event, - post_event=post_event, - ), - ) + with ctx.manager(event, state=state) as mgr: + if pre_event: + pre_event(mgr.charm) + out.append(mgr.run()) + if post_event: + post_event(mgr.charm) return out diff --git a/scenario/state.py b/scenario/state.py index 2f77f48c..3458e510 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -8,7 +8,6 @@ import datetime import inspect import re -import warnings from collections import namedtuple from enum import Enum from itertools import chain @@ -213,10 +212,6 @@ class Secret: # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None - # deprecated! if a secret is not granted to this unit, omit it from State.secrets altogether. - # this attribute will be removed in Scenario 7+ - granted: Any = "" # noqa - # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -229,27 +224,6 @@ class Secret: expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None - def __post_init__(self): - if self.granted != "": - msg = ( - "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. " - "If a Secret is not owned by the app/unit you are testing, nor has been granted to " - "it by the (remote) owner, then omit it from ``State.secrets`` altogether." - ) - logger.warning(msg) - warnings.warn(msg, DeprecationWarning, stacklevel=2) - - if self.owner == "application": - msg = ( - "Secret.owner='application' is deprecated in favour of 'app' " - "and will be removed in Scenario 7+." - ) - logger.warning(msg) - warnings.warn(msg, DeprecationWarning, stacklevel=2) - - # bypass frozen dataclass - object.__setattr__(self, "owner", "app") - # consumer-only events @property def changed_event(self): @@ -973,11 +947,6 @@ class _EntityStatus: message: str = "" def __eq__(self, other): - if isinstance(other, Tuple): - logger.warning( - "Comparing Status with Tuples is deprecated and will be removed soon.", - ) - return (self.name, self.message) == other if isinstance(other, (StatusBase, _EntityStatus)): return (self.name, self.message) == (other.name, other.message) logger.warning( diff --git a/scenario/strategies.py b/scenario/strategies.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/helpers.py b/tests/helpers.py index 7558e78d..712b62d1 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -49,12 +49,13 @@ def trigger( charm_root=charm_root, juju_version=juju_version, ) - return ctx.run( - event, - state=state, - pre_event=pre_event, - post_event=post_event, - ) + with ctx.manager(event, state=state) as mgr: + if pre_event: + pre_event(mgr.charm) + state_out = mgr.run() + if post_event: + post_event(mgr.charm) + return state_out def jsonpatch_delta(input: "State", output: "State"): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 5282c195..30cc9d1e 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -44,23 +44,6 @@ def test_action_event(mycharm, baz_value): assert evt.params["baz"] is baz_value -def test_action_pre_post(mycharm): - ctx = Context( - mycharm, - meta={"name": "foo"}, - actions={ - "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} - }, - ) - action = Action("foo", params={"baz": True, "bar": 10}) - ctx.run_action( - action, - State(), - pre_event=lambda charm: None, - post_event=lambda charm: None, - ) - - @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) def test_action_event_results_invalid(mycharm, res_value): def handle_evt(charm: CharmBase, evt: ActionEvent): diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index e7fefb7a..28cbe516 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -41,19 +41,6 @@ def test_manager(mycharm): assert manager.output # still there! -def test_manager_legacy_pre_post_hooks(mycharm): - ctx = Context(mycharm, meta=mycharm.META) - post_event = MagicMock() - pre_event = MagicMock() - - ctx.run("start", State(), pre_event=pre_event, post_event=post_event) - - assert post_event.called - assert isinstance(post_event.call_args.args[0], mycharm) - assert pre_event.called - assert isinstance(pre_event.call_args.args[0], mycharm) - - def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventManager(ctx, "start", State()) as manager: diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index f6143252..865ffb30 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -130,11 +130,9 @@ def callback(self: CharmBase): charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, ) - out = ctx.run( - event="start", - state=state, - post_event=callback, - ) + with ctx.manager("start", state=state) as mgr: + out = mgr.run() + callback(mgr.charm) if make_dirs: # file = (out.get_container("foo").mounts["foo"].src + "bar/baz.txt").open("/foo/bar/baz.txt") @@ -233,37 +231,42 @@ def callback(self: CharmBase): @pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus) def test_pebble_plan(charm_cls, starting_service_status): - def callback(self: CharmBase): - foo = self.unit.get_container("foo") + class PlanCharm(charm_cls): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_ready, self._on_ready) - assert foo.get_plan().to_dict() == { - "services": {"fooserv": {"startup": "enabled"}} - } - fooserv = foo.get_services("fooserv")["fooserv"] - assert fooserv.startup == ServiceStartup.ENABLED - assert fooserv.current == ServiceStatus.ACTIVE - - foo.add_layer( - "bar", - { - "summary": "bla", - "description": "deadbeef", - "services": {"barserv": {"startup": "disabled"}}, - }, - ) + def _on_ready(self, event): + foo = event.workload - foo.replan() - assert foo.get_plan().to_dict() == { - "services": { - "barserv": {"startup": "disabled"}, - "fooserv": {"startup": "enabled"}, + assert foo.get_plan().to_dict() == { + "services": {"fooserv": {"startup": "enabled"}} } - } + fooserv = foo.get_services("fooserv")["fooserv"] + assert fooserv.startup == ServiceStartup.ENABLED + assert fooserv.current == ServiceStatus.ACTIVE - assert foo.get_service("barserv").current == starting_service_status - foo.start("barserv") - # whatever the original state, starting a service sets it to active - assert foo.get_service("barserv").current == ServiceStatus.ACTIVE + foo.add_layer( + "bar", + { + "summary": "bla", + "description": "deadbeef", + "services": {"barserv": {"startup": "disabled"}}, + }, + ) + + foo.replan() + assert foo.get_plan().to_dict() == { + "services": { + "barserv": {"startup": "disabled"}, + "fooserv": {"startup": "enabled"}, + } + } + + assert foo.get_service("barserv").current == starting_service_status + foo.start("barserv") + # whatever the original state, starting a service sets it to active + assert foo.get_service("barserv").current == ServiceStatus.ACTIVE container = Container( name="foo", @@ -286,10 +289,9 @@ def callback(self: CharmBase): out = trigger( State(containers=[container]), - charm_type=charm_cls, + charm_type=PlanCharm, meta={"name": "foo", "containers": {"foo": {}}}, event=container.pebble_ready_event, - post_event=callback, ) serv = lambda name, obj: pebble.Service(name, raw=obj) diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index ccff9366..dc10b3a9 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -1,5 +1,5 @@ import pytest -from ops import CharmBase +from ops import CharmBase, Framework, StartEvent, StopEvent from scenario import Context, State from scenario.state import Port @@ -8,6 +8,18 @@ class MyCharm(CharmBase): META = {"name": "edgar"} + def __init__(self, framework: Framework): + super().__init__(framework) + framework.observe(self.on.start, self._open_port) + framework.observe(self.on.stop, self._close_port) + + def _open_port(self, _: StartEvent): + self.unit.open_port("tcp", 12) + + def _close_port(self, _: StopEvent): + assert self.unit.opened_ports() + self.unit.close_port("tcp", 42) + @pytest.fixture def ctx(): @@ -15,10 +27,7 @@ def ctx(): def test_open_port(ctx): - def post_event(charm: CharmBase): - charm.unit.open_port("tcp", 12) - - out = ctx.run("start", State(), post_event=post_event) + out = ctx.run("start", State()) port = out.opened_ports.pop() assert port.protocol == "tcp" @@ -26,9 +35,5 @@ def post_event(charm: CharmBase): def test_close_port(ctx): - def post_event(charm: CharmBase): - assert charm.unit.opened_ports() - charm.unit.close_port("tcp", 42) - - out = ctx.run("start", State(opened_ports={Port("tcp", 42)}), post_event=post_event) + out = ctx.run("stop", State(opened_ports=[Port("tcp", 42)])) assert not out.opened_ports diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 26988245..4fa38363 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -45,23 +45,13 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): with Context(mycharm, meta={"name": "local"}).manager( - state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted=True)]), + state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), event="update_status", ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" -def test_get_secret_not_granted(mycharm): - with Context(mycharm, meta={"name": "local"}).manager( - state=State(secrets=[]), - event="update_status", - ) as mgr: - with pytest.raises(SecretNotFoundError) as e: - assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" - - -@pytest.mark.parametrize("owner", ("app", "unit", "application")) -# "application" is deprecated but still supported +@pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_get_refresh(mycharm, owner): with Context(mycharm, meta={"name": "local"}).manager( "update_status", @@ -108,8 +98,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): assert charm.model.get_secret(id="foo").get_content()["a"] == "c" -@pytest.mark.parametrize("owner", ("app", "unit", "application")) -# "application" is deprecated but still supported +@pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_owner_peek_update(mycharm, owner): with Context(mycharm, meta={"name": "local"}).manager( "update_status", @@ -132,8 +121,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" -@pytest.mark.parametrize("owner", ("app", "unit", "application")) -# "application" is deprecated but still supported +@pytest.mark.parametrize("owner", ("app", "unit")) def test_secret_changed_owner_evt_fails(mycharm, owner): with pytest.raises(ValueError): _ = Secret( @@ -312,32 +300,6 @@ def test_meta(mycharm, app): assert info.rotation == SecretRotate.HOURLY -def test_secret_deprecation_application(mycharm): - with warnings.catch_warnings(record=True) as captured: - s = Secret("123", {}, owner="application") - assert s.owner == "app" - msg = captured[0].message - assert isinstance(msg, DeprecationWarning) - assert msg.args[0] == ( - "Secret.owner='application' is deprecated in favour of " - "'app' and will be removed in Scenario 7+." - ) - - -@pytest.mark.parametrize("granted", ("app", "unit", False)) -def test_secret_deprecation_granted(mycharm, granted): - with warnings.catch_warnings(record=True) as captured: - s = Secret("123", {}, granted=granted) - assert s.granted == granted - msg = captured[0].message - assert isinstance(msg, DeprecationWarning) - assert msg.args[0] == ( - "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. " - "If a Secret is not owned by the app/unit you are testing, nor has been granted to " - "it by the (remote) owner, then omit it from ``State.secrets`` altogether." - ) - - @pytest.mark.parametrize("leader", (True, False)) @pytest.mark.parametrize("owner", ("app", "unit", None)) def test_secret_permission_model(mycharm, leader, owner): diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 477440d6..0c79da86 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -177,7 +177,6 @@ def event_handler(charm: CharmBase, _): # this will NOT raise an exception because we're not in an event context! # we're right before the event context is entered in fact. - # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state? with pytest.raises(Exception): rel.data[rel.app]["a"] = "b" with pytest.raises(Exception): @@ -191,7 +190,6 @@ def pre_event(charm: CharmBase): # this would NOT raise an exception because we're not in an event context! # we're right before the event context is entered in fact. - # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state? # with pytest.raises(Exception): # rel.data[rel.app]["a"] = "b" # with pytest.raises(Exception): diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index 0c28f7e6..e587b406 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -45,22 +45,23 @@ def post_event(charm: CharmBase): def test_status_history(mycharm): - def post_event(charm: CharmBase): - for obj in [charm.unit, charm.app]: - obj.status = ActiveStatus("1") - obj.status = BlockedStatus("2") - obj.status = WaitingStatus("3") + class StatusCharm(mycharm): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.update_status, self._on_update_status) + + def _on_update_status(self, _): + for obj in (self.unit, self.app): + obj.status = ActiveStatus("1") + obj.status = BlockedStatus("2") + obj.status = WaitingStatus("3") ctx = Context( - mycharm, + StatusCharm, meta={"name": "local"}, ) - out = ctx.run( - "update_status", - State(leader=True), - post_event=post_event, - ) + out = ctx.run("update_status", State(leader=True)) assert out.unit_status == WaitingStatus("3") assert ctx.unit_status_history == [ @@ -78,12 +79,17 @@ def post_event(charm: CharmBase): def test_status_history_preservation(mycharm): - def post_event(charm: CharmBase): - for obj in [charm.unit, charm.app]: - obj.status = WaitingStatus("3") + class StatusCharm(mycharm): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.update_status, self._on_update_status) + + def _on_update_status(self, _): + for obj in (self.unit, self.app): + obj.status = WaitingStatus("3") ctx = Context( - mycharm, + StatusCharm, meta={"name": "local"}, ) @@ -94,7 +100,6 @@ def post_event(charm: CharmBase): unit_status=ActiveStatus("foo"), app_status=ActiveStatus("bar"), ), - post_event=post_event, ) assert out.unit_status == WaitingStatus("3") @@ -105,23 +110,30 @@ def post_event(charm: CharmBase): def test_workload_history(mycharm): - def post_event(charm: CharmBase): - charm.unit.set_workload_version("1") - charm.unit.set_workload_version("1.1") - charm.unit.set_workload_version("1.2") + class WorkloadCharm(mycharm): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.install, self._on_install) + framework.observe(self.on.start, self._on_start) + framework.observe(self.on.update_status, self._on_update_status) + + def _on_install(self, _): + self.unit.set_workload_version("1") + + def _on_start(self, _): + self.unit.set_workload_version("1.1") + + def _on_update_status(self, _): + self.unit.set_workload_version("1.2") ctx = Context( - mycharm, + WorkloadCharm, meta={"name": "local"}, ) - out = ctx.run( - "update_status", - State( - leader=True, - ), - post_event=post_event, - ) + out = ctx.run("install", State(leader=True)) + out = ctx.run("start", out) + out = ctx.run("update_status", out) assert ctx.workload_version_history == ["1", "1.1"] assert out.workload_version == "1.2" diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index c6d59be5..6b6f902e 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -47,7 +47,7 @@ def test_charm_virtual_root(charm_virtual_root): meta=MyCharm.META, charm_root=charm_virtual_root, ) - assert out.unit_status == ("active", "hello world") + assert out.unit_status == ActiveStatus("hello world") def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py deleted file mode 100644 index e69de29b..00000000 From 3b8e28950741661a3732851a8ad9be14e97582e5 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 11:49:12 +1300 Subject: [PATCH 06/35] Make RelationBase private (_RelationBase). --- scenario/__init__.py | 2 -- scenario/state.py | 9 ++++----- tests/test_e2e/test_relations.py | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index dfe567f0..fdd42ae7 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -19,7 +19,6 @@ PeerRelation, Port, Relation, - RelationBase, Secret, State, StateValidationError, @@ -38,7 +37,6 @@ "deferred", "StateValidationError", "Secret", - "RelationBase", "Relation", "SubordinateRelation", "PeerRelation", diff --git a/scenario/state.py b/scenario/state.py index 3458e510..7b1958c9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -392,7 +392,7 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) -class RelationBase: +class _RelationBase: endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -431,9 +431,9 @@ def _get_databag_for_remote( raise NotImplementedError() def __post_init__(self): - if type(self) is RelationBase: + if type(self) is _RelationBase: raise RuntimeError( - "RelationBase cannot be instantiated directly; " + "_RelationBase cannot be instantiated directly; " "please use Relation, PeerRelation, or SubordinateRelation", ) @@ -504,7 +504,6 @@ def broken_event(self) -> "Event": @dataclasses.dataclass(frozen=True) class Relation(RelationBase): """An integration between the charm and another application.""" - remote_app_name: str = "remote" """The name of the remote application, as in the charm's metadata.""" @@ -543,7 +542,7 @@ def _databags(self): @dataclasses.dataclass(frozen=True) -class SubordinateRelation(RelationBase): +class SubordinateRelation(_RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 212e12a6..57ad0769 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -15,10 +15,10 @@ DEFAULT_JUJU_DATABAG, PeerRelation, Relation, - RelationBase, State, StateValidationError, SubordinateRelation, + _RelationBase, ) from tests.helpers import trigger @@ -379,7 +379,7 @@ def post_event(charm: CharmBase): def test_cannot_instantiate_relationbase(): with pytest.raises(RuntimeError): - RelationBase("") + _RelationBase("") def test_relation_ids(): From 065cfd2ba647c66755006b31ac4387e4f949dece Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 11:44:31 +1300 Subject: [PATCH 07/35] Add basic StoredState consistency checks. Update scenario/consistency_checker.py Co-authored-by: PietroPasotti Fix broken files. Update scenario/mocking.py --- scenario/consistency_checker.py | 35 ++++++++++++++++++++++- scenario/runtime.py | 2 +- scenario/state.py | 8 ++++-- tests/test_consistency_checker.py | 46 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 895e6f8f..41d4ac83 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import marshal import os import re -from collections import Counter +from collections import Counter, defaultdict from collections.abc import Sequence from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union @@ -71,6 +72,7 @@ def check_consistency( check_relation_consistency, check_network_consistency, check_cloudspec_consistency, + check_storedstate_consistency, ): results = check( state=state, @@ -598,3 +600,34 @@ def check_cloudspec_consistency( ) return Results(errors, warnings) + + +def check_storedstate_consistency( + *, + state: "State", + **_kwargs, # noqa: U101 +) -> Results: + """Check the internal consistency of `state.storedstate`.""" + errors = [] + + # Attribute names must be unique on each object. + names = defaultdict(list) + for ss in state.stored_state: + names[ss.owner_path].append(ss.name) + for owner, owner_names in names.items(): + if len(owner_names) != len(set(owner_names)): + errors.append( + f"{owner} has multiple StoredState objects with the same name.", + ) + + # The content must be marshallable. + for ss in state.stored_state: + # We don't need the marshalled state, just to know that it can be. + # This is the same "only simple types" check that ops does. + try: + marshal.dumps(ss.content) + except ValueError: + errors.append( + f"The StoredState object {ss.owner_path}.{ss.name} should contain only simple types.", + ) + return Results(errors, []) diff --git a/scenario/runtime.py b/scenario/runtime.py index 4395c6dd..73f6e28d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -32,7 +32,7 @@ logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( - r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", + r"((?P.*)\/)?(?P<_data_type_name>\D+)\[(?P.*)\]", ) EVENT_REGEX = re.compile(_event_regex) diff --git a/scenario/state.py b/scenario/state.py index 7b1958c9..0a31d1cc 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -980,13 +980,17 @@ class StoredState: owner_path: Optional[str] name: str = "_stored" + # 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) - data_type_name: str = "StoredStateData" + _data_type_name: str = "StoredStateData" @property def handle_path(self): - return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" + return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]" _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index a5ecdb73..f25b9179 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -20,6 +20,7 @@ Secret, State, Storage, + StoredState, SubordinateRelation, _CharmSpec, ) @@ -624,3 +625,48 @@ def test_cloudspec_consistency(): meta={"name": "MyK8sCharm"}, ), ) + + +def test_storedstate_consistency(): + assert_consistent( + State( + stored_state=[ + StoredState(None, content={"foo": "bar"}), + StoredState(None, "my_stored_state", content={"foo": 1}), + StoredState("MyCharmLib", content={"foo": None}), + StoredState("OtherCharmLib", content={"foo": (1, 2, 3)}), + ] + ), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "foo", + }, + ), + ) + assert_inconsistent( + State( + stored_state=[ + StoredState(None, content={"foo": "bar"}), + StoredState(None, "_stored", content={"foo": "bar"}), + ] + ), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "foo", + }, + ), + ) + assert_inconsistent( + State(stored_state=[StoredState(None, content={"secret": Secret("foo", {})})]), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "foo", + }, + ), + ) From fcd5d9d53133fe3dc80343f30d98f0cca24783bf Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 30 May 2024 19:45:47 +1200 Subject: [PATCH 08/35] Temporarily enable quality checks for the 7.0 branch. --- .github/workflows/quality_checks.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index 5e1159aa..2f0e309a 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - 7.0 jobs: linting: @@ -41,4 +42,4 @@ jobs: - name: Install dependencies run: python -m pip install tox - name: Run unit tests - run: tox -vve unit \ No newline at end of file + run: tox -vve unit From 1d0aa9be596d90f3d56eb7963331466f69fd8c2b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 17:40:48 +1200 Subject: [PATCH 09/35] Test the code in the README. --- README.md | 236 ++++++++++----------------------------- tests/readme-conftest.py | 56 ++++++++++ tox.ini | 17 +++ 3 files changed, 131 insertions(+), 178 deletions(-) create mode 100644 tests/readme-conftest.py diff --git a/README.md b/README.md index 2cc8f863..3c195e89 100644 --- a/README.md +++ b/README.md @@ -82,14 +82,6 @@ available. The charm has no config, no relations, no leadership, and its status With that, we can write the simplest possible scenario test: ```python -import ops -import scenario - - -class MyCharm(ops.CharmBase): - pass - - def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(scenario.Event("start"), scenario.State()) @@ -99,9 +91,7 @@ def test_scenario_base(): Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python -import ops import pytest -import scenario class MyCharm(ops.CharmBase): @@ -133,9 +123,6 @@ sets the expected unit/application status. We have seen a simple example above i charm transitions through a sequence of statuses? ```python -import ops - - # charm code: def _on_event(self, _event): self.unit.status = ops.MaintenanceStatus('determining who the ruler is...') @@ -174,11 +161,6 @@ context. You can verify that the charm has followed the expected path by checking the unit/app status history like so: ```python -import ops -import scenario -from charm import MyCharm - - def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run('start', scenario.State(leader=False)) @@ -187,7 +169,7 @@ def test_statuses(): ops.MaintenanceStatus('determining who the ruler is...'), ops.WaitingStatus('checking this is right...'), ] - assert out.unit_status == ops.ActiveStatus("I am ruled"), + assert out.unit_status == ops.ActiveStatus("I am ruled") # similarly you can check the app status history: assert ctx.app_status_history == [ @@ -206,10 +188,16 @@ If you want to simulate a situation in which the charm already has seen some eve Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. ```python -import ops -import scenario +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event): + self.model.unit.status = ops.ActiveStatus("foo") # ... +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! @@ -223,10 +211,9 @@ Using a similar api to `*_status_history`, you can assert that the charm has set hook execution: ```python -import scenario - # ... -ctx: scenario.Context +ctx = scenario.Context(HistoryCharm, meta={"name": "foo"}) +ctx.run("start", scenario.State()) assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` @@ -241,10 +228,6 @@ given Juju event triggering (say, 'start'), a specific chain of events is emitte resulting state, black-box as it is, gives little insight into how exactly it was obtained. ```python -import ops -import scenario - - def test_foo(): ctx = scenario.Context(...) ctx.run('start', ...) @@ -259,8 +242,6 @@ You can configure what events will be captured by passing the following argument For example: ```python -import scenario - def test_emitted_full(): ctx = scenario.Context( MyCharm, @@ -288,14 +269,13 @@ This context manager allows you to intercept any events emitted by the framework Usage: ```python -import ops -import scenario +import scenario.capture_events -with capture_events() as emitted: - ctx = scenario.Context(...) +with scenario.capture_events.capture_events() as emitted: + ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( "update-status", - scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) + scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) ) # deferred events get reemitted first @@ -310,9 +290,9 @@ assert isinstance(emitted[1], ops.UpdateStatusEvent) You can filter events by type like so: ```python -import ops +import scenario.capture_events -with capture_events(ops.StartEvent, ops.RelationEvent) as emitted: +with scenario.capture_events.capture_events(ops.StartEvent, ops.RelationEvent) as emitted: # capture all `start` and `*-relation-*` events. pass ``` @@ -330,10 +310,6 @@ Configuration: You can write scenario tests to verify the shape of relation data: ```python -import ops -import scenario - - # This charm copies over remote app data to local unit data class MyCharm(ops.CharmBase): ... @@ -395,8 +371,6 @@ have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms o - `Relation.remote_units_data` maps to `PeerRelation.peers_data` ```python -import scenario - relation = scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, @@ -407,15 +381,22 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee be flagged by the Consistency Checker: ```python -import scenario - state_in = scenario.State(relations=[ scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, )]) -scenario.Context(..., unit_id=1).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. +meta = { + "name": "invalid", + "peers": { + "peers": { + "interface": "foo", + } + } +} +ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) +ctx.run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. ``` @@ -432,8 +413,6 @@ Because of that, `SubordinateRelation`, compared to `Relation`, always talks in - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) ```python -import scenario - relation = scenario.SubordinateRelation( endpoint="peers", remote_unit_data={"foo": "bar"}, @@ -449,8 +428,6 @@ If you want to trigger relation events, the easiest way to do so is get a hold o event from one of its aptly-named properties: ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = relation.changed_event joined_event = relation.joined_event @@ -460,8 +437,6 @@ joined_event = relation.joined_event This is in fact syntactic sugar for: ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = scenario.Event('foo-relation-changed', relation=relation) ``` @@ -486,12 +461,11 @@ This can be handy when using `replace` to create new relations, to avoid relatio ```python import dataclasses -from scenario import Relation -from scenario.state import next_relation_id +import scenario.state -rel = Relation('foo') -rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, relation_id=next_relation_id()) -assert rel2.relation_id == rel.relation_id + 1 +rel = scenario.Relation('foo') +rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, id=scenario.state.next_relation_id()) +assert rel2.id == rel.id + 1 ``` If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. @@ -511,8 +485,6 @@ The `remote_unit_id` will default to the first ID found in the relation's `remot writing is close to that domain, you should probably override it and pass it manually. ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) @@ -532,8 +504,6 @@ On top of the relation-provided network bindings, a charm can also define some ` If you want to, you can override any of these relation or extra-binding associated networks with a custom one by passing it to `State.networks`. ```python -import scenario - state = scenario.State(networks={ 'foo': scenario.Network.default(private_address='192.0.2.1') }) @@ -552,8 +522,6 @@ To give the charm access to some containers, you need to pass them to the input An example of a state including some containers: ```python -import scenario - state = scenario.State(containers=[ scenario.Container(name="foo", can_connect=True), scenario.Container(name="bar", can_connect=False) @@ -569,14 +537,12 @@ You can configure a container to have some files in it: ```python import pathlib -import scenario - local_file = pathlib.Path('/path/to/local/real/file.txt') container = scenario.Container( name="foo", can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file)} + mounts={'local': scenario.Mount('/local/share/config.yaml', local_file)} ) state = scenario.State(containers=[container]) ``` @@ -597,9 +563,6 @@ data and passing it to the charm via the container. ```python import tempfile -import ops -import scenario - class MyCharm(ops.CharmBase): def __init__(self, framework): @@ -640,10 +603,6 @@ that envvar into the charm's runtime. If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. ```python -import ops -import scenario - - class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) @@ -677,9 +636,6 @@ worse issues to deal with. You need to specify, for each possible command the ch result of that would be: its return code, what will be written to stdout/stderr. ```python -import ops -import scenario - LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml .rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml @@ -757,10 +713,8 @@ If your charm defines `storage` in its metadata, you can use `scenario.Storage` Using the same `get_filesystem` API as `Container`, you can access the temporary directory used by Scenario to mock the filesystem root before and after the scenario runs. ```python -import scenario - # Some charm with a 'foo' filesystem-type storage defined in its metadata: -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) storage = scenario.Storage("foo") # Setup storage with some content: @@ -788,7 +742,7 @@ Note that State only wants to know about **attached** storages. A storage which If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. -```python +```python notest # In MyCharm._on_foo: # The charm requests two new "foo" storage instances to be provisioned: self.model.storages.request("foo", 2) @@ -796,10 +750,8 @@ self.model.storages.request("foo", 2) From test code, you can inspect that: -```python -import scenario - -ctx = scenario.Context(MyCharm) +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State()) # the charm has requested two 'foo' storages to be provisioned: @@ -810,16 +762,14 @@ Requesting storages has no other consequence in Scenario. In real life, this req So a natural follow-up Scenario test suite for this case would be: ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(foo_0.attached_event, State(storage=[foo_0])) +ctx.run(foo_0.attached_event, scenario.State(storage=[foo_0])) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) +ctx.run(foo_1.attached_event, scenario.State(storage=[foo_0, foo_1])) ``` ## Ports @@ -828,16 +778,12 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(PortCharm, meta=MyCharm.META) state1 = ctx.run("start", scenario.State()) assert state1.opened_ports == [scenario.Port("tcp", 42)] @@ -850,8 +796,6 @@ assert state2.opened_ports == [] Scenario has secrets. Here's how you use them. ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -881,8 +825,6 @@ If this charm does not own the secret, but also it was not granted view rights b To specify a secret owned by this unit (or app): ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -899,8 +841,6 @@ state = scenario.State( To specify a secret owned by some other application and give this unit (or app) access to it: ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -918,12 +858,6 @@ state = scenario.State( Scenario can simulate StoredState. You can define it on the input side as: ```python -import ops -import scenario - -from ops.charm import CharmBase - - class MyCharmType(ops.CharmBase): my_stored_state = ops.StoredState() @@ -955,13 +889,13 @@ However, when testing, this constraint is unnecessarily strict (and it would als So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in the metadata. ```python -import scenario +import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') - assert path == '/path/to/resource.tar' + assert path == pathlib.Path('/path/to/resource.tar') ``` ## Model @@ -971,12 +905,6 @@ but if you need to set the model name or UUID, you can provide a `scenario.Model to the state: ```python -import ops -import scenario - -class MyCharm(ops.CharmBase): - pass - ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_in = scenario.State(model=scenario.Model(name="my-model")) out = ctx.run("start", state_in) @@ -1035,11 +963,6 @@ How to test actions with scenario: ## Actions without parameters ```python -import scenario - -from charm import MyCharm - - def test_backup_action(): ctx = scenario.Context(MyCharm) @@ -1064,11 +987,6 @@ def test_backup_action(): If the action takes parameters, you'll need to instantiate an `Action`. ```python -import scenario - -from charm import MyCharm - - def test_backup_action(): # Define an action: action = scenario.Action('do_backup', params={'a': 'b'}) @@ -1089,10 +1007,7 @@ event in its queue (they would be there because they had been deferred in the pr valid. ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_update_status(self, event): @@ -1117,14 +1032,7 @@ def test_start_on_deferred_update_status(MyCharm): You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the handler): -```python -import scenario - - -class MyCharm(...): - ... - - +```python continuation deferred_start = scenario.Event('start').deferred(MyCharm._on_start) deferred_install = scenario.Event('install').deferred(MyCharm._on_start) ``` @@ -1133,10 +1041,7 @@ On the output side, you can verify that an event that you expect to have been de been deferred. ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_start(self, event): @@ -1156,10 +1061,7 @@ Relation instance they are about. So do they in Scenario. You can use the deferr structure: ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_foo_relation_changed(self, event): @@ -1180,14 +1082,7 @@ def test_start_on_deferred_update_status(MyCharm): but you can also use a shortcut from the relation event itself: -```python -import scenario - - -class MyCharm(...): - ... - - +```python continuation foo_relation = scenario.Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` @@ -1200,8 +1095,6 @@ charm libraries or objects other than the main charm class. For general-purpose usage, you will need to instantiate DeferredEvent directly. ```python -import scenario - my_deferred_event = scenario.DeferredEvent( handle_path='MyCharm/MyCharmLib/on/database_ready[1]', owner='MyCharmLib', # the object observing the event. Could also be MyCharm. @@ -1223,10 +1116,9 @@ the event is emitted at all. If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: -```python -import scenario - -scenario.Context(...).run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) +ctx.run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError ``` This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but @@ -1234,10 +1126,9 @@ since the event is defined on another Object, it will fail to find it. You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: -```python -import scenario - -scenario.Context(...).run("my_charm_lib.on.foo", scenario.State()) +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) +ctx.run("my_charm_lib.on.foo", scenario.State()) ``` This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. @@ -1252,10 +1143,7 @@ given piece of data, or would return this and that _if_ it had been called. Scenario offers a cheekily-named context manager for this use case specifically: -```python -import ops -import scenario - +```python notest from charms.bar.lib_name.v1.charm_lib import CharmLib @@ -1309,16 +1197,12 @@ either inferred from the charm type being passed to `Context` or be passed to it the inferred one. This also allows you to test charms defined on the fly, as in: ```python -import ops -import scenario - - class MyCharmType(ops.CharmBase): pass ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) -ctx.run('start', State()) +ctx.run('start', scenario.State()) ``` A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata @@ -1327,9 +1211,6 @@ you are passing to `.run()` (because `ops` expects it to be a file...). That is, ```python import tempfile -import ops -import scenario - class MyCharmType(ops.CharmBase): pass @@ -1362,11 +1243,10 @@ the dataclasses `replace` api. ```python import dataclasses -from scenario import Relation relation = scenario.Relation('foo', remote_app_data={"1": "2"}) -# make a copy of relation, but with remote_app_data set to {"3", "4"} -relation2 = dataclasses.replace(relation, remote_app_data={"3", "4"}) +# make a copy of relation, but with remote_app_data set to {"3": "4"} +relation2 = dataclasses.replace(relation, remote_app_data={"3": "4"}) ``` # Consistency checks diff --git a/tests/readme-conftest.py b/tests/readme-conftest.py new file mode 100644 index 00000000..d125b930 --- /dev/null +++ b/tests/readme-conftest.py @@ -0,0 +1,56 @@ +"""pytest configuration for testing the README""" + +import ops + +import scenario + + +def pytest_markdown_docs_globals(): + class MyCharm(ops.CharmBase): + META = {"name": "mycharm", "storage": {"foo": {"type": "filesystem"}}} + + class SimpleCharm(ops.CharmBase): + META = {"name": "simplecharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _: ops.StartEvent): + pass + + class HistoryCharm(ops.CharmBase): + META = {"name": "historycharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _: ops.StartEvent): + self.unit.set_workload_version("1") + self.unit.set_workload_version("1.2") + self.unit.set_workload_version("1.5") + self.unit.set_workload_version("2.0") + + class PortCharm(ops.CharmBase): + META = {"name": "portcharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + framework.observe(self.on.stop, self._on_stop) + + def _on_start(self, _: ops.StartEvent): + self.unit.open_port(protocol="tcp", port=42) + + def _on_stop(self, _: ops.StopEvent): + self.unit.close_port(protocol="tcp", port=42) + + return { + "ops": ops, + "scenario": scenario, + "MyCharm": MyCharm, + "HistoryCharm": HistoryCharm, + "PortCharm": PortCharm, + "SimpleCharm": SimpleCharm, + } diff --git a/tox.ini b/tox.ini index 273c8e29..a404c5f6 100644 --- a/tox.ini +++ b/tox.ini @@ -82,3 +82,20 @@ commands_pre = pip-sync {toxinidir}/docs/requirements.txt commands = sphinx-build -W --keep-going docs/ docs/_build/html + +[testenv:test-readme] +description = Test code snippets in the README. +skip_install = true +allowlist_externals = + mkdir + cp +deps = + . + ops + pytest + pytest-markdown-docs +commands = + mkdir -p {envtmpdir}/test-readme + cp {toxinidir}/README.md {envtmpdir}/test-readme/README.md + cp {toxinidir}/tests/readme-conftest.py {envtmpdir}/test-readme/conftest.py + pytest -v --tb native --log-cli-level=INFO -s {posargs} --markdown-docs {envtmpdir}/test-readme/README.md From 468cf1ab69cf98b0c5f64367bb9eb7a840db6ae7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 17:49:10 +1200 Subject: [PATCH 10/35] Remove scenario.sequences (and related tests). --- scenario/sequences.py | 131 -------------------------- tests/test_e2e/test_builtin_scenes.py | 56 ----------- 2 files changed, 187 deletions(-) delete mode 100644 scenario/sequences.py delete mode 100644 tests/test_e2e/test_builtin_scenes.py diff --git a/scenario/sequences.py b/scenario/sequences.py deleted file mode 100644 index 2e1cbb22..00000000 --- a/scenario/sequences.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import copy -import dataclasses -import typing -from itertools import chain -from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union - -from scenario import Context -from scenario.logger import logger as scenario_logger -from scenario.state import ( - ATTACH_ALL_STORAGES, - BREAK_ALL_RELATIONS, - CREATE_ALL_RELATIONS, - DETACH_ALL_STORAGES, - Event, - State, -) - -if typing.TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - -CharmMeta = Optional[Union[str, TextIO, dict]] -logger = scenario_logger.getChild("scenario") - - -def decompose_meta_event(meta_event: Event, state: State): - # decompose the meta event - - if meta_event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: - logger.warning(f"meta-event {meta_event.name} not supported yet") - return - - is_rel_created_meta_event = meta_event.name == CREATE_ALL_RELATIONS - is_rel_broken_meta_event = meta_event.name == BREAK_ALL_RELATIONS - if is_rel_broken_meta_event: - for relation in state.relations: - event = relation.broken_event - logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, copy.deepcopy(state) - elif is_rel_created_meta_event: - for relation in state.relations: - event = relation.created_event - logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, copy.deepcopy(state) - else: - raise RuntimeError(f"unknown meta-event {meta_event.name}") - - -def generate_startup_sequence(state_template: State): - yield from chain( - decompose_meta_event(Event(ATTACH_ALL_STORAGES), copy.deepcopy(state_template)), - ((Event("start"), copy.deepcopy(state_template)),), - decompose_meta_event( - Event(CREATE_ALL_RELATIONS), - copy.deepcopy(state_template), - ), - ( - ( - Event( - ( - "leader_elected" - if state_template.leader - else "leader_settings_changed" - ), - ), - copy.deepcopy(state_template), - ), - (Event("config_changed"), copy.deepcopy(state_template)), - (Event("install"), copy.deepcopy(state_template)), - ), - ) - - -def generate_teardown_sequence(state_template: State): - yield from chain( - decompose_meta_event(Event(BREAK_ALL_RELATIONS), copy.deepcopy(state_template)), - decompose_meta_event(Event(DETACH_ALL_STORAGES), copy.deepcopy(state_template)), - ( - (Event("stop"), copy.deepcopy(state_template)), - (Event("remove"), copy.deepcopy(state_template)), - ), - ) - - -def generate_builtin_sequences(template_states: Iterable[State]): - for template_state in template_states: - yield from chain( - generate_startup_sequence(template_state), - generate_teardown_sequence(template_state), - ) - - -def check_builtin_sequences( - charm_type: Type["CharmType"], - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - template_state: State = None, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, -) -> object: - """Test that all the builtin startup and teardown events can fire without errors. - - This will play both scenarios with and without leadership, and raise any exceptions. - - This is a baseline check that in principle all charms (except specific use-cases perhaps), - should pass out of the box. - - If you want to, you can inject more stringent state checks using the - pre_event and post_event hooks. - """ - - template = template_state if template_state else State() - out = [] - - for event, state in generate_builtin_sequences( - ( - dataclasses.replace(template, leader=True), - dataclasses.replace(template, leader=False), - ), - ): - ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) - with ctx.manager(event, state=state) as mgr: - if pre_event: - pre_event(mgr.charm) - out.append(mgr.run()) - if post_event: - post_event(mgr.charm) - return out diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py deleted file mode 100644 index 587e4149..00000000 --- a/tests/test_e2e/test_builtin_scenes.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from ops.charm import CharmBase, CollectStatusEvent -from ops.framework import Framework - -from scenario.sequences import check_builtin_sequences -from scenario.state import State - -CHARM_CALLED = 0 - - -@pytest.fixture(scope="function") -def mycharm(): - global CHARM_CALLED - CHARM_CALLED = 0 - - class MyCharm(CharmBase): - _call = None - require_config = False - - def __init__(self, framework: Framework): - super().__init__(framework) - self.called = False - if self.require_config: - assert self.config["foo"] == "bar" - - for evt in self.on.events().values(): - self.framework.observe(evt, self._on_event) - - def _on_event(self, event): - if isinstance(event, CollectStatusEvent): - return - - global CHARM_CALLED - CHARM_CALLED += 1 - - if self._call: - self.called = True - self._call(event) - - return MyCharm - - -def test_builtin_scenes(mycharm): - check_builtin_sequences(mycharm, meta={"name": "foo"}) - assert CHARM_CALLED == 12 - - -def test_builtin_scenes_template(mycharm): - mycharm.require_config = True - check_builtin_sequences( - mycharm, - meta={"name": "foo"}, - config={"options": {"foo": {"type": "string"}}}, - template_state=State(config={"foo": "bar"}), - ) - assert CHARM_CALLED == 12 From aa66be56dadd62cdd8e92a5ceda2af162aebceff Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Apr 2024 14:31:41 +1200 Subject: [PATCH 11/35] Support 'ctx.on.event_name' for specifying events. Fix typo in comment. Remove support for directly running custom events. Update tests and docs to match final (hopefully\!) API decision. Style fixes. Fix tests. The failing test are (a) ones that need to be rewritten for the new system, (b) ones to do with custom events. Remove the old shortcuts on state components. Remove old event properties from Secret. Style fixes. Move the checks that were on binding to the consistency checker. Update rubbish event tests. These are not as valuable without support for emitting custom events directly, but it seems worthwhile to leave them in so that if we add custom event emitting back in the future they're here to build off. Update tests now that emitting custom events is not possible. Minor clean-up. Fix typo found in review. Fix tests for relation.unit. --- README.md | 99 ++---- scenario/__init__.py | 2 - scenario/consistency_checker.py | 60 +++- scenario/context.py | 254 +++++++++----- scenario/mocking.py | 6 +- scenario/ops_main_mock.py | 18 +- scenario/runtime.py | 22 +- scenario/state.py | 197 +---------- tests/helpers.py | 13 +- tests/test_charm_spec_autoload.py | 8 +- tests/test_consistency_checker.py | 204 ++++++----- tests/test_context.py | 16 +- tests/test_context_on.py | 338 +++++++++++++++++++ tests/test_e2e/test_actions.py | 10 +- tests/test_e2e/test_custom_event_triggers.py | 146 -------- tests/test_e2e/test_deferred.py | 27 +- tests/test_e2e/test_event.py | 10 +- tests/test_e2e/test_event_bind.py | 62 ---- tests/test_e2e/test_juju_log.py | 3 +- tests/test_e2e/test_manager.py | 8 +- tests/test_e2e/test_network.py | 6 +- tests/test_e2e/test_pebble.py | 21 +- tests/test_e2e/test_ports.py | 4 +- tests/test_e2e/test_relations.py | 70 ++-- tests/test_e2e/test_rubbish_events.py | 13 +- tests/test_e2e/test_secrets.py | 134 +++++--- tests/test_e2e/test_status.py | 10 +- tests/test_e2e/test_storage.py | 18 +- tests/test_e2e/test_vroot.py | 10 +- tests/test_emitted_events_util.py | 36 +- tests/test_plugin.py | 2 +- tests/test_runtime.py | 10 +- tox.ini | 2 +- 33 files changed, 983 insertions(+), 856 deletions(-) create mode 100644 tests/test_context_on.py delete mode 100644 tests/test_e2e/test_custom_event_triggers.py delete mode 100644 tests/test_e2e/test_event_bind.py diff --git a/README.md b/README.md index 3c195e89..e69d061c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ With that, we can write the simplest possible scenario test: ```python def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run(scenario.Event("start"), scenario.State()) + out = ctx.run(ctx.on.start(), scenario.State()) assert out.unit_status == ops.UnknownStatus() ``` @@ -109,7 +109,7 @@ class MyCharm(ops.CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', scenario.State(leader=leader)) + out = ctx.run(ctx.on.start(), scenario.State(leader=leader)) assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled') ``` @@ -163,7 +163,7 @@ You can verify that the charm has followed the expected path by checking the uni ```python def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', scenario.State(leader=False)) + out = ctx.run(ctx.on.start(), scenario.State(leader=False)) assert ctx.unit_status_history == [ ops.UnknownStatus(), ops.MaintenanceStatus('determining who the ruler is...'), @@ -197,8 +197,7 @@ class MyCharm(ops.CharmBase): self.model.unit.status = ops.ActiveStatus("foo") # ... -ctx = scenario.Context(MyCharm, meta={"name": "foo"}) -ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo'))) +ctx.run(ctx.on.start(), scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! # ... @@ -230,7 +229,7 @@ resulting state, black-box as it is, gives little insight into how exactly it wa ```python def test_foo(): ctx = scenario.Context(...) - ctx.run('start', ...) + ctx.run(ctx.on.start(), ...) assert len(ctx.emitted_events) == 1 assert isinstance(ctx.emitted_events[0], ops.StartEvent) @@ -248,7 +247,7 @@ def test_emitted_full(): capture_deferred_events=True, capture_framework_events=True, ) - ctx.run("start", scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) + ctx.run(ctx.on.start(), scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ @@ -274,8 +273,8 @@ import scenario.capture_events with scenario.capture_events.capture_events() as emitted: ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( - "update-status", - scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) + ctx.on.update_status(), + scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) ) # deferred events get reemitted first @@ -333,7 +332,7 @@ def test_relation_data(): ]) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - state_out = ctx.run('start', state_in) + state_out = ctx.run(ctx.on.start(), state_in) assert state_out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: @@ -396,9 +395,7 @@ meta = { } } ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) -ctx.run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. - - +ctx.run(ctx.on.start(), state_in) # invalid: this unit's id cannot be the ID of a peer. ``` ### SubordinateRelation @@ -587,7 +584,7 @@ def test_pebble_push(): meta={"name": "foo", "containers": {"foo": {}}} ) ctx.run( - container.pebble_ready_event(), + ctx.on.pebble_ready(container), state_in, ) assert local_file.read().decode() == "TEST" @@ -621,7 +618,7 @@ def test_pebble_push(): meta={"name": "foo", "containers": {"foo": {}}} ) - ctx.run("start", state_in) + ctx.run(ctx.on.start(), state_in) # This is the root of the simulated container filesystem. Any mounts will be symlinks in it. container_root_fs = container.get_filesystem(ctx) @@ -667,7 +664,7 @@ def test_pebble_exec(): meta={"name": "foo", "containers": {"foo": {}}}, ) state_out = ctx.run( - container.pebble_ready_event, + ctx.on.pebble_ready(container), state_in, ) ``` @@ -752,7 +749,7 @@ From test code, you can inspect that: ```python notest ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State()) +ctx.run(ctx.on.some_event_that_will_cause_on_foo_to_be_called(), scenario.State()) # the charm has requested two 'foo' storages to be provisioned: assert ctx.requested_storages['foo'] == 2 @@ -765,11 +762,11 @@ So a natural follow-up Scenario test suite for this case would be: ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(foo_0.attached_event, scenario.State(storage=[foo_0])) +ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storage=[foo_0])) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(foo_1.attached_event, scenario.State(storage=[foo_0, foo_1])) +ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storage=[foo_0, foo_1])) ``` ## Ports @@ -779,15 +776,15 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ```python ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)])) +ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.Port("tcp", 42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python ctx = scenario.Context(PortCharm, meta=MyCharm.META) -state1 = ctx.run("start", scenario.State()) +state1 = ctx.run(ctx.on.start(), scenario.State()) assert state1.opened_ports == [scenario.Port("tcp", 42)] -state2 = ctx.run("stop", state1) +state2 = ctx.run(ctx.on.stop(), state1) assert state2.opened_ports == [] ``` @@ -907,7 +904,7 @@ to the state: ```python ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_in = scenario.State(model=scenario.Model(name="my-model")) -out = ctx.run("start", state_in) +out = ctx.run(ctx.on.start(), state_in) assert out.model.name == "my-model" assert out.model.uuid == state_in.model.uuid ``` @@ -1024,7 +1021,7 @@ def test_start_on_deferred_update_status(MyCharm): scenario.deferred('update_status', handler=MyCharm._on_update_status) ] ) - state_out = scenario.Context(MyCharm).run('start', state_in) + state_out = scenario.Context(MyCharm).run(ctx.on.start(), state_in) assert len(state_out.deferred) == 1 assert state_out.deferred[0].name == 'start' ``` @@ -1049,7 +1046,7 @@ class MyCharm(ops.CharmBase): def test_defer(MyCharm): - out = scenario.Context(MyCharm).run('start', scenario.State()) + out = scenario.Context(MyCharm).run(ctx.on.start(), scenario.State()) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` @@ -1087,54 +1084,6 @@ foo_relation = scenario.Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` -## Fine-tuning - -The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by -charm libraries or objects other than the main charm class. - -For general-purpose usage, you will need to instantiate DeferredEvent directly. - -```python -my_deferred_event = scenario.DeferredEvent( - handle_path='MyCharm/MyCharmLib/on/database_ready[1]', - owner='MyCharmLib', # the object observing the event. Could also be MyCharm. - observer='_on_database_ready' -) -``` - -# Emitting custom events - -While the main use case of Scenario is to emit Juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, -etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy. - -Suppose your charm uses a charm library providing an `ingress_provided` event. -The 'proper' way to emit it is to run the event that causes that custom event to be emitted by the library, whatever -that may be, for example a `foo-relation-changed`. - -However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and -the event is emitted at all. - -If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: - -```python notest -ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError -``` - -This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but -since the event is defined on another Object, it will fail to find it. - -You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: - -```python notest -ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run("my_charm_lib.on.foo", scenario.State()) -``` - -This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. - -(always omit the 'root', i.e. the charm framework key, from the path) - # Live charm introspection Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to @@ -1202,7 +1151,7 @@ class MyCharmType(ops.CharmBase): ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) -ctx.run('start', scenario.State()) +ctx.run(ctx.on.start(), scenario.State()) ``` A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata @@ -1221,7 +1170,7 @@ state = scenario.Context( charm_type=MyCharmType, meta={'name': 'my-charm-name'}, charm_root=td.name -).run('start', scenario.State()) +).run(ctx.on.start(), scenario.State()) ``` Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its diff --git a/scenario/__init__.py b/scenario/__init__.py index fdd42ae7..93059ebf 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -10,7 +10,6 @@ CloudSpec, Container, DeferredEvent, - Event, ExecOutput, Model, Mount, @@ -53,5 +52,4 @@ "StoredState", "State", "DeferredEvent", - "Event", ] diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 41d4ac83..004032fa 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -20,7 +20,7 @@ ) if TYPE_CHECKING: # pragma: no cover - from scenario.state import Event, State + from scenario.state import State, _Event logger = scenario_logger.getChild("consistency_checker") @@ -34,7 +34,7 @@ class Results(NamedTuple): def check_consistency( state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", juju_version: str, ): @@ -120,8 +120,9 @@ def check_resource_consistency( def check_event_consistency( *, - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", + state: "State", **_kwargs, # noqa: U101 ) -> Results: """Check the internal consistency of the Event data structure. @@ -142,23 +143,24 @@ def check_event_consistency( ) if event._is_relation_event: - _check_relation_event(charm_spec, event, errors, warnings) + _check_relation_event(charm_spec, event, state, errors, warnings) if event._is_workload_event: - _check_workload_event(charm_spec, event, errors, warnings) + _check_workload_event(charm_spec, event, state, errors, warnings) if event._is_action_event: - _check_action_event(charm_spec, event, errors, warnings) + _check_action_event(charm_spec, event, state, errors, warnings) if event._is_storage_event: - _check_storage_event(charm_spec, event, errors, warnings) + _check_storage_event(charm_spec, event, state, errors, warnings) return Results(errors, warnings) def _check_relation_event( charm_spec: _CharmSpec, # noqa: U100 - event: "Event", + event: "_Event", + state: "State", errors: List[str], warnings: List[str], # noqa: U100 ): @@ -173,11 +175,16 @@ def _check_relation_event( f"relation event should start with relation endpoint name. {event.name} does " f"not start with {event.relation.endpoint}.", ) + if event.relation not in state.relations: + errors.append( + f"cannot emit {event.name} because relation {event.relation.id} is not in the state.", + ) def _check_workload_event( charm_spec: _CharmSpec, # noqa: U100 - event: "Event", + event: "_Event", + state: "State", errors: List[str], warnings: List[str], # noqa: U100 ): @@ -191,11 +198,22 @@ def _check_workload_event( f"workload event should start with container name. {event.name} does " f"not start with {event.container.name}.", ) + if event.container not in state.containers: + errors.append( + f"cannot emit {event.name} because container {event.container.name} " + f"is not in the state.", + ) + if not event.container.can_connect: + warnings.append( + "you **can** fire fire pebble-ready while the container cannot connect, " + "but that's most likely not what you want.", + ) def _check_action_event( charm_spec: _CharmSpec, - event: "Event", + event: "_Event", + state: "State", # noqa: U100 errors: List[str], warnings: List[str], ): @@ -224,7 +242,8 @@ def _check_action_event( def _check_storage_event( charm_spec: _CharmSpec, - event: "Event", + event: "_Event", + state: "State", errors: List[str], warnings: List[str], # noqa: U100 ): @@ -246,6 +265,11 @@ def _check_storage_event( f"storage event {event.name} refers to storage {storage.name} " f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", ) + elif storage not in state.storage: + errors.append( + f"cannot emit {event.name} because storage {storage.name} " + f"is not in the state.", + ) def _check_action_param_types( @@ -396,7 +420,7 @@ def check_config_consistency( def check_secrets_consistency( *, - event: "Event", + event: "_Event", state: "State", juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 @@ -406,9 +430,11 @@ def check_secrets_consistency( if not event._is_secret_event: return Results(errors, []) - if not state.secrets: + assert event.secret is not None + if event.secret not in state.secrets: + secret_key = event.secret.id if event.secret.id else event.secret.label errors.append( - "the event being processed is a secret event; but the state has no secrets.", + f"cannot emit {event.name} because secret {secret_key} is not in the state.", ) elif juju_version < (3,): errors.append( @@ -422,7 +448,7 @@ def check_secrets_consistency( def check_network_consistency( *, state: "State", - event: "Event", # noqa: U100 + event: "_Event", # noqa: U100 charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: @@ -459,7 +485,7 @@ def check_network_consistency( def check_relation_consistency( *, state: "State", - event: "Event", # noqa: U100 + event: "_Event", # noqa: U100 charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: @@ -528,7 +554,7 @@ def _get_relations(r): def check_containers_consistency( *, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: diff --git a/scenario/context.py b/scenario/context.py index 25eb9f66..db990e54 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -11,13 +11,21 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime -from scenario.state import Action, Event, MetadataNotFoundError, _CharmSpec +from scenario.state import ( + Action, + Container, + MetadataNotFoundError, + Secret, + Storage, + _CharmSpec, + _Event, +) if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType from scenario.ops_main_mock import Ops - from scenario.state import JujuLogLine, State, _EntityStatus + from scenario.state import AnyRelation, JujuLogLine, State, _EntityStatus PathLike = Union[str, Path] @@ -72,7 +80,7 @@ class _Manager: def __init__( self, ctx: "Context", - arg: Union[str, Action, Event], + arg: Union[str, Action, _Event], state_in: "State", ): self._ctx = ctx @@ -157,6 +165,153 @@ def _get_output(self): return self._ctx._finalize_action(self._ctx.output_state) # noqa +class _CharmEvents: + """Events generated by Juju pertaining to application lifecycle. + + By default, the events listed as attributes of this class will be + provided 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__ + 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). + """ + + @staticmethod + def install(): + return _Event("install") + + @staticmethod + def start(): + return _Event("start") + + @staticmethod + def stop(): + return _Event("stop") + + @staticmethod + def remove(): + return _Event("remove") + + @staticmethod + def update_status(): + return _Event("update_status") + + @staticmethod + def config_changed(): + return _Event("config_changed") + + @staticmethod + def upgrade_charm(): + return _Event("upgrade_charm") + + @staticmethod + def pre_series_upgrade(): + return _Event("pre_series_upgrade") + + @staticmethod + def post_series_upgrade(): + return _Event("post_series_upgrade") + + @staticmethod + def leader_elected(): + return _Event("leader_elected") + + @staticmethod + def secret_changed(secret: Secret): + if secret.owner: + raise ValueError( + "This unit will never receive secret-changed for a secret it owns.", + ) + return _Event("secret_changed", secret=secret) + + @staticmethod + def secret_expired(secret: Secret, *, revision: int): + if not secret.owner: + raise ValueError( + "This unit will never receive secret-expire for a secret it does not own.", + ) + return _Event("secret_expired", secret=secret, secret_revision=revision) + + @staticmethod + def secret_rotate(secret: Secret): + if not secret.owner: + raise ValueError( + "This unit will never receive secret-rotate for a secret it does not own.", + ) + return _Event("secret_rotate", secret=secret) + + @staticmethod + def secret_remove(secret: Secret, *, revision: int): + if not secret.owner: + raise ValueError( + "This unit will never receive secret-removed for a secret it does not own.", + ) + return _Event("secret_remove", secret=secret, secret_revision=revision) + + @staticmethod + def collect_app_status(): + return _Event("collect_app_status") + + @staticmethod + def collect_unit_status(): + return _Event("collect_unit_status") + + @staticmethod + def relation_created(relation: "AnyRelation"): + return _Event(f"{relation.endpoint}_relation_created", relation=relation) + + @staticmethod + def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + return _Event( + f"{relation.endpoint}_relation_joined", + relation=relation, + relation_remote_unit_id=remote_unit, + ) + + @staticmethod + def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + return _Event( + f"{relation.endpoint}_relation_changed", + relation=relation, + relation_remote_unit_id=remote_unit, + ) + + @staticmethod + def relation_departed( + relation: "AnyRelation", + *, + remote_unit: Optional[int] = None, + departing_unit: Optional[int] = None, + ): + return _Event( + f"{relation.endpoint}_relation_departed", + relation=relation, + relation_remote_unit_id=remote_unit, + relation_departed_unit_id=departing_unit, + ) + + @staticmethod + def relation_broken(relation: "AnyRelation"): + return _Event(f"{relation.endpoint}_relation_broken", relation=relation) + + @staticmethod + def storage_attached(storage: Storage): + return _Event(f"{storage.name}_storage_attached", storage=storage) + + @staticmethod + def storage_detaching(storage: Storage): + return _Event(f"{storage.name}_storage_detaching", storage=storage) + + @staticmethod + def pebble_ready(container: Container): + return _Event(f"{container.name}_pebble_ready", container=container) + + class Context: """Represents a simulated charm's execution context. @@ -280,7 +435,7 @@ def __init__( >>> # Arrange: set the context up >>> c = Context(MyCharm) >>> # Act: prepare the state and emit an event - >>> state_out = c.run('update-status', State()) + >>> state_out = c.run(c.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 @@ -367,6 +522,8 @@ def __init__( self._action_results: Optional[Dict[str, str]] = None self._action_failure: Optional[str] = None + self.on = _CharmEvents() + def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" self._output_state = output_state @@ -402,39 +559,7 @@ def _record_status(self, state: "State", is_app: bool): else: self.unit_status_history.append(cast("_EntityStatus", state.unit_status)) - @staticmethod - def _coalesce_action(action: Union[str, Action]) -> Action: - """Validate the action argument and cast to Action.""" - if isinstance(action, str): - return Action(action) - - if not isinstance(action, Action): - raise InvalidActionError( - f"Expected Action or action name; got {type(action)}", - ) - return action - - @staticmethod - def _coalesce_event(event: Union[str, Event]) -> Event: - """Validate the event argument and cast to Event.""" - if isinstance(event, str): - event = Event(event) - - if not isinstance(event, Event): - raise InvalidEventError(f"Expected Event | str, got {type(event)}") - - if event._is_action_event: # noqa - raise InvalidEventError( - "Cannot Context.run() action events. " - "Use Context.run_action instead.", - ) - return event - - def manager( - self, - event: Union["Event", str], - state: "State", - ): + def manager(self, event: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage:: @@ -450,69 +575,53 @@ def manager( """ return _EventManager(self, event, state) - def action_manager( - self, - action: Union["Action", str], - state: "State", - ): + def action_manager(self, action: "Action", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage: - >>> with Context().action_manager("foo-action", State()) as manager: + >>> with Context().action_manager(Action("foo"), State()) as manager: >>> assert manager.charm._some_private_attribute == "foo" # noqa >>> manager.run() # this will fire the event >>> assert manager.charm._some_private_attribute == "bar" # noqa - :arg action: the Action that the charm will execute. Can be a string or an Action instance. + :arg action: the Action that the charm will execute. :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Action (event). """ return _ActionManager(self, action, state) @contextmanager - def _run_event( - self, - event: Union["Event", str], - state: "State", - ): - _event = self._coalesce_event(event) - with self._run(event=_event, state=state) as ops: + def _run_event(self, event: "_Event", state: "State"): + with self._run(event=event, state=state) as ops: yield ops - def run( - self, - event: Union["Event", str], - state: "State", - ) -> "State": + def run(self, event: "_Event", state: "State") -> "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. - :arg event: the Event that the charm will respond to. Can be a string or an Event instance. + :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. """ + if isinstance(event, Action) or event.action: + raise InvalidEventError("Use run_action() to run an action event.") with self._run_event(event=event, state=state) as ops: ops.emit() return self.output_state - def run_action( - self, - action: Union["Action", str], - state: "State", - ) -> ActionOutput: + def run_action(self, action: "Action", state: "State") -> ActionOutput: """Trigger a charm execution with an Action 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. - :arg action: the Action that the charm will execute. Can be a string or an Action instance. + :arg action: the Action that the charm will execute. :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Action (event). """ - _action = self._coalesce_action(action) - with self._run_action(action=_action, state=state) as ops: + with self._run_action(action=action, state=state) as ops: ops.emit() return self._finalize_action(self.output_state) @@ -532,21 +641,12 @@ def _finalize_action(self, state_out: "State"): return ao @contextmanager - def _run_action( - self, - action: Union["Action", str], - state: "State", - ): - _action = self._coalesce_action(action) - with self._run(event=_action.event, state=state) as ops: + def _run_action(self, action: "Action", state: "State"): + with self._run(event=action.event, state=state) as ops: yield ops @contextmanager - def _run( - self, - event: "Event", - state: "State", - ): + def _run(self, event: "_Event", state: "State"): runtime = Runtime( charm_spec=self.charm_spec, juju_version=self.juju_version, diff --git a/scenario/mocking.py b/scenario/mocking.py index ac56d719..5f2c17c6 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -49,13 +49,13 @@ from scenario.context import Context from scenario.state import Container as ContainerSpec from scenario.state import ( - Event, ExecOutput, Relation, Secret, State, SubordinateRelation, _CharmSpec, + _Event, ) logger = scenario_logger.getChild("mocking") @@ -102,7 +102,7 @@ class _MockModelBackend(_ModelBackend): def __init__( self, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", context: "Context", ): @@ -655,7 +655,7 @@ def __init__( mounts: Dict[str, Mount], *, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", ): self._state = state diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 8b2845c8..d2a0371a 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: # pragma: no cover from scenario.context import Context - from scenario.state import Event, State, _CharmSpec + from scenario.state import State, _CharmSpec, _Event # pyright: reportPrivateUsage=false @@ -66,7 +66,7 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: def _emit_charm_event( charm: "CharmBase", event_name: str, - event: Optional["Event"] = None, + event: Optional["_Event"] = None, ): """Emits a charm event based on a Juju event name. @@ -83,8 +83,7 @@ def _emit_charm_event( ops_logger.debug("Event %s not defined for %s.", event_name, charm) raise NoObserverError( f"Cannot fire {event_name!r} on {owner}: " - f"invalid event (not on charm.on). " - f"Use Context.run_custom instead.", + f"invalid event (not on charm.on).", ) try: @@ -105,7 +104,7 @@ def _emit_charm_event( def setup_framework( charm_dir, state: "State", - event: "Event", + event: "_Event", context: "Context", charm_spec: "_CharmSpec", ): @@ -176,7 +175,12 @@ def setup_charm(charm_class, framework, dispatcher): return charm -def setup(state: "State", event: "Event", context: "Context", charm_spec: "_CharmSpec"): +def setup( + state: "State", + event: "_Event", + context: "Context", + charm_spec: "_CharmSpec", +): """Setup dispatcher, framework and charm objects.""" charm_class = charm_spec.charm_type charm_dir = _get_charm_dir() @@ -204,7 +208,7 @@ class Ops: def __init__( self, state: "State", - event: "Event", + event: "_Event", context: "Context", charm_spec: "_CharmSpec", ): diff --git a/scenario/runtime.py b/scenario/runtime.py index 73f6e28d..114e66a9 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -26,7 +26,7 @@ from ops.testing import CharmType from scenario.context import Context - from scenario.state import Event, State, _CharmSpec + from scenario.state import State, _CharmSpec, _Event PathLike = Union[str, Path] @@ -181,7 +181,7 @@ def _cleanup_env(env): # os.unsetenv does not always seem to work !? del os.environ[key] - def _get_event_env(self, state: "State", event: "Event", charm_root: Path): + def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): """Build the simulated environment the operator framework expects.""" env = { "JUJU_VERSION": self._juju_version, @@ -219,7 +219,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): remote_unit_id = event.relation_remote_unit_id # don't check truthiness because remote_unit_id could be 0 - if remote_unit_id is None: + if remote_unit_id is None and not event.name.endswith( + ("_relation_created", "relation_broken"), + ): remote_unit_ids = relation._remote_unit_ids # pyright: ignore if len(remote_unit_ids) == 1: @@ -246,7 +248,12 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): remote_unit = f"{remote_app_name}/{remote_unit_id}" env["JUJU_REMOTE_UNIT"] = remote_unit if event.name.endswith("_relation_departed"): - env["JUJU_DEPARTING_UNIT"] = remote_unit + if event.relation_departed_unit_id: + env[ + "JUJU_DEPARTING_UNIT" + ] = f"{remote_app_name}/{event.relation_departed_unit_id}" + else: + env["JUJU_DEPARTING_UNIT"] = remote_unit if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) @@ -274,8 +281,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "JUJU_SECRET_LABEL": secret.label or "", }, ) - if event.name in ("secret_remove", "secret_expired"): - env["JUJU_SECRET_REVISION"] = str(secret.revision) + # Don't check truthiness because revision could be 0. + if event.secret_revision is not None: + env["JUJU_SECRET_REVISION"] = str(event.secret_revision) return env @@ -398,7 +406,7 @@ def _exec_ctx(self, ctx: "Context"): def exec( self, state: "State", - event: "Event", + event: "_Event", context: "Context", ): """Runs an event with this state as initial state on a charm. diff --git a/scenario/state.py b/scenario/state.py index 0a31d1cc..de814e3d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -123,10 +123,6 @@ class MetadataNotFoundError(RuntimeError): """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" -class BindFailedError(RuntimeError): - """Raised when Event.bind fails.""" - - @dataclasses.dataclass(frozen=True) class CloudCredential: auth_type: str @@ -224,44 +220,6 @@ class Secret: expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None - # consumer-only events - @property - def changed_event(self): - """Sugar to generate a secret-changed event.""" - if self.owner: - raise ValueError( - "This unit will never receive secret-changed for a secret it owns.", - ) - return Event("secret_changed", secret=self) - - # owner-only events - @property - def rotate_event(self): - """Sugar to generate a secret-rotate event.""" - if not self.owner: - raise ValueError( - "This unit will never receive secret-rotate for a secret it does not own.", - ) - return Event("secret_rotate", secret=self) - - @property - def expired_event(self): - """Sugar to generate a secret-expired event.""" - if not self.owner: - raise ValueError( - "This unit will never receive secret-expired for a secret it does not own.", - ) - return Event("secret_expired", secret=self) - - @property - def remove_event(self): - """Sugar to generate a secret-remove event.""" - if not self.owner: - raise ValueError( - "This unit will never receive secret-remove for a secret it does not own.", - ) - return Event("secret_remove", secret=self) - def _set_revision(self, revision: int): """Set a new tracked revision.""" # bypass frozen dataclass @@ -452,46 +410,6 @@ def _validate_databag(self, databag: dict): f"found a value of type {type(v)}", ) - @property - def changed_event(self) -> "Event": - """Sugar to generate a -relation-changed event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-changed"), - relation=cast("AnyRelation", self), - ) - - @property - def joined_event(self) -> "Event": - """Sugar to generate a -relation-joined event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-joined"), - relation=cast("AnyRelation", self), - ) - - @property - def created_event(self) -> "Event": - """Sugar to generate a -relation-created event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-created"), - relation=cast("AnyRelation", self), - ) - - @property - def departed_event(self) -> "Event": - """Sugar to generate a -relation-departed event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-departed"), - relation=cast("AnyRelation", self), - ) - - @property - def broken_event(self) -> "Event": - """Sugar to generate a -relation-broken event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-broken"), - relation=cast("AnyRelation", self), - ) - _DEFAULT_IP = " 192.0.2.0" DEFAULT_JUJU_DATABAG = { @@ -898,16 +816,6 @@ def get_filesystem(self, ctx: "Context") -> Path: """ return ctx._get_container_root(self.name) - @property - def pebble_ready_event(self): - """Sugar to generate a -pebble-ready event.""" - if not self.can_connect: - logger.warning( - "you **can** fire pebble-ready while the container cannot connect, " - "but that's most likely not what you want.", - ) - return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) - def get_notice( self, key: str, @@ -925,7 +833,6 @@ def get_notice( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) - _RawStatusLiteral = Literal[ "waiting", "blocked", @@ -1052,22 +959,6 @@ def get_filesystem(self, ctx: "Context") -> Path: """Simulated filesystem root in this context.""" return ctx._get_storage_root(self.name, self.index) - @property - def attached_event(self) -> "Event": - """Sugar to generate a -storage-attached event.""" - return Event( - path=normalize_name(self.name + "-storage-attached"), - storage=self, - ) - - @property - def detaching_event(self) -> "Event": - """Sugar to generate a -storage-detached event.""" - return Event( - path=normalize_name(self.name + "-storage-detaching"), - storage=self, - ) - @dataclasses.dataclass(frozen=True) class State: @@ -1415,11 +1306,14 @@ class Event: relation: Optional["AnyRelation"] = None """If this is a relation event, the relation it refers to.""" relation_remote_unit_id: Optional[int] = None - """If this is a relation event, the name of the remote unit the event is about.""" + relation_departed_unit_id: Optional[int] = None secret: Optional[Secret] = None """If this is a secret event, the secret it refers to.""" + # if this is a secret-removed or secret-expired event, the secret revision it refers to + secret_revision: Optional[int] = None + container: Optional[Container] = None """If this is a workload (container) event, the container it refers to.""" @@ -1429,21 +1323,8 @@ class Event: action: Optional["Action"] = None """If this is an action event, the :class:`Action` it refers to.""" - # TODO: add other meta for - # - secret events - # - pebble? - # - action? - _owner_path: List[str] = dataclasses.field(default_factory=list) - def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": - if remote_unit_id and not self._is_relation_event: - raise ValueError( - "cannot pass param `remote_unit_id` to a " - "non-relation event constructor.", - ) - return dataclasses.replace(self, relation_remote_unit_id=remote_unit_id) - def __post_init__(self): path = _EventPath(self.path) # bypass frozen dataclass @@ -1521,68 +1402,6 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"): # assuming it is owned by the charm, LOOKS LIKE that of a builtin event or not. return self._path.type is not _EventType.custom - def bind(self, state: State): - """Attach to this event the state component it needs. - - For example, a relation event initialized without a Relation instance will search for - a suitable relation in the provided state and return a copy of itself with that - relation attached. - - In case of ambiguity (e.g. multiple relations found on 'foo' for event - 'foo-relation-changed', we pop a warning and bind the first one. Use with care! - """ - entity_name = self._path.prefix - - if self._is_workload_event and not self.container: - try: - container = state.get_container(entity_name) - except ValueError: - raise BindFailedError(f"no container found with name {entity_name}") - return dataclasses.replace(self, container=container) - - if self._is_secret_event and not self.secret: - if len(state.secrets) < 1: - raise BindFailedError(f"no secrets found in state: cannot bind {self}") - if len(state.secrets) > 1: - raise BindFailedError( - f"too many secrets found in state: cannot automatically bind {self}", - ) - return dataclasses.replace(self, secret=state.secrets[0]) - - if self._is_storage_event and not self.storage: - storages = state.get_storages(entity_name) - if len(storages) < 1: - raise BindFailedError( - f"no storages called {entity_name} found in state", - ) - if len(storages) > 1: - logger.warning( - f"too many storages called {entity_name}: binding to first one", - ) - storage = storages[0] - return dataclasses.replace(self, storage=storage) - - if self._is_relation_event and not self.relation: - ep_name = entity_name - relations = state.get_relations(ep_name) - if len(relations) < 1: - raise BindFailedError(f"no relations on {ep_name} found in state") - if len(relations) > 1: - logger.warning(f"too many relations on {ep_name}: binding to first one") - return dataclasses.replace(self, relation=relations[0]) - - if self._is_action_event and not self.action: - raise BindFailedError( - "cannot automatically bind action events: if the action has mandatory parameters " - "this would probably result in horrible, undebuggable failures downstream.", - ) - - else: - raise BindFailedError( - f"cannot bind {self}: only relation, secret, " - f"or workload events can be bound.", - ) - def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" handler_repr = repr(handler) @@ -1682,13 +1501,13 @@ def test_backup_action(): the rare cases where a specific ID is required.""" @property - def event(self) -> Event: + def event(self) -> _Event: """Helper to generate an action event from this action.""" - return Event(self.name + ACTION_EVENT_SUFFIX, action=self) + return _Event(self.name + ACTION_EVENT_SUFFIX, action=self) def deferred( - event: Union[str, Event], + event: Union[str, _Event], handler: Callable, event_id: int = 1, relation: Optional["Relation"] = None, @@ -1697,5 +1516,5 @@ def deferred( ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - event = Event(event, relation=relation, container=container, notice=notice) + event = _Event(event, relation=relation, container=container, notice=notice) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/helpers.py b/tests/helpers.py index 712b62d1..7dd1f835 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType - from scenario.state import Event, State + from scenario.state import State, _Event _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -31,7 +31,7 @@ def trigger( state: "State", - event: Union["Event", str], + event: Union[str, "_Event"], charm_type: Type["CharmType"], pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, @@ -49,6 +49,15 @@ def trigger( charm_root=charm_root, juju_version=juju_version, ) + if isinstance(event, str): + if event.startswith("relation_"): + assert len(state.relations) == 1, "shortcut only works with one relation" + event = getattr(ctx.on, event)(state.relations[0]) + elif event.startswith("pebble_"): + assert len(state.containers) == 1, "shortcut only works with one container" + event = getattr(ctx.on, event)(state.containers[0]) + else: + event = getattr(ctx.on, event)() with ctx.manager(event, state=state) as mgr: if pre_event: pre_event(mgr.charm) diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 89a9cfdf..51ba1391 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -115,7 +115,7 @@ def test_meta_autoload(tmp_path, legacy): meta={"type": "charm", "name": "foo", "summary": "foo", "description": "foo"}, ) as charm: ctx = Context(charm) - ctx.run("start", State()) + ctx.run(ctx.on.start(), State()) @pytest.mark.parametrize("legacy", (True, False)) @@ -143,7 +143,8 @@ def test_relations_ok(tmp_path, legacy): }, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta - Context(charm).run("start", State(relations=[Relation("cuddles")])) + ctx = Context(charm) + ctx.run(ctx.on.start(), State(relations=[Relation("cuddles")])) @pytest.mark.parametrize("legacy", (True, False)) @@ -160,6 +161,7 @@ def test_config_defaults(tmp_path, legacy): config={"options": {"foo": {"type": "bool", "default": True}}}, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta - with Context(charm).manager("start", State()) as mgr: + ctx = Context(charm) + with ctx.manager(ctx.on.start(), State()) as mgr: mgr.run() assert mgr.charm.config["foo"] is True diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index f25b9179..217a68d9 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -12,7 +12,6 @@ CloudCredential, CloudSpec, Container, - Event, Network, Notice, PeerRelation, @@ -23,6 +22,7 @@ StoredState, SubordinateRelation, _CharmSpec, + _Event, ) @@ -32,7 +32,7 @@ class MyCharm(CharmBase): def assert_inconsistent( state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", juju_version="3.0", ): @@ -42,7 +42,7 @@ def assert_inconsistent( def assert_consistent( state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", juju_version="3.0", ): @@ -51,7 +51,7 @@ def assert_consistent( def test_base(): state = State() - event = Event("update_status") + event = _Event("update_status") spec = _CharmSpec(MyCharm, {}) assert_consistent(state, event, spec) @@ -59,12 +59,12 @@ def test_base(): def test_workload_event_without_container(): assert_inconsistent( State(), - Event("foo-pebble-ready", container=Container("foo")), + _Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("foo")]), - Event("foo-pebble-ready", container=Container("foo")), + _Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) assert_inconsistent( @@ -88,12 +88,12 @@ def test_workload_event_without_container(): def test_container_meta_mismatch(): assert_inconsistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), _CharmSpec(MyCharm, {"containers": {"baz": {}}}), ) assert_consistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -101,12 +101,26 @@ def test_container_meta_mismatch(): def test_container_in_state_but_no_container_in_meta(): assert_inconsistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + + +def test_container_not_in_state(): + container = Container("bar") + assert_inconsistent( + State(), + _Event("bar_pebble_ready", container=container), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + assert_consistent( + State(containers=[container]), + _Event("bar_pebble_ready", container=container), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -114,12 +128,12 @@ def test_container_in_state_but_no_container_in_meta(): def test_evt_bad_container_name(): assert_inconsistent( State(), - Event("foo-pebble-ready", container=Container("bar")), + _Event("foo-pebble-ready", container=Container("bar")), _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("bar")]), - Event("bar-pebble-ready", container=Container("bar")), + _Event("bar-pebble-ready", container=Container("bar")), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -128,22 +142,24 @@ def test_evt_bad_container_name(): def test_evt_bad_relation_name(suffix): assert_inconsistent( State(), - Event(f"foo{suffix}", relation=Relation("bar")), + _Event(f"foo{suffix}", relation=Relation("bar")), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "xxx"}}}), ) + relation = Relation("bar") assert_consistent( - State(relations=[Relation("bar")]), - Event(f"bar{suffix}", relation=Relation("bar")), + State(relations=[relation]), + _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) def test_evt_no_relation(suffix): - assert_inconsistent(State(), Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) + assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) + relation = Relation("bar") assert_consistent( - State(relations=[Relation("bar")]), - Event(f"bar{suffix}", relation=Relation("bar")), + State(relations=[relation]), + _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @@ -151,12 +167,12 @@ def test_evt_no_relation(suffix): def test_config_key_missing_from_meta(): assert_inconsistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}), ) assert_consistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}), ) @@ -164,17 +180,17 @@ def test_config_key_missing_from_meta(): def test_bad_config_option_type(): assert_inconsistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "string"}}}), ) assert_inconsistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {}}}), ) assert_consistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}), ) @@ -192,12 +208,12 @@ def test_config_types(config_type): type_name, valid_value, invalid_value = config_type assert_consistent( State(config={"foo": valid_value}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}), ) assert_inconsistent( State(config={"foo": invalid_value}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}), ) @@ -206,28 +222,28 @@ def test_config_types(config_type): def test_config_secret(juju_version): assert_consistent( State(config={"foo": "secret:co28kefmp25c77utl3n0"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), juju_version=juju_version, ) assert_inconsistent( State(config={"foo": 1}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) assert_inconsistent( State(config={"foo": "co28kefmp25c77utl3n0"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) assert_inconsistent( State(config={"foo": "secret:secret"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) assert_inconsistent( State(config={"foo": "secret:co28kefmp25c77utl3n!"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) @@ -236,7 +252,7 @@ def test_config_secret(juju_version): def test_config_secret_old_juju(juju_version): assert_inconsistent( State(config={"foo": "secret:co28kefmp25c77utl3n0"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), juju_version=juju_version, ) @@ -247,7 +263,7 @@ def test_secrets_jujuv_bad(bad_v): secret = Secret("secret:foo", {0: {"a": "b"}}) assert_inconsistent( State(secrets=[secret]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}), bad_v, ) @@ -270,21 +286,35 @@ def test_secrets_jujuv_bad(bad_v): def test_secrets_jujuv_bad(good_v): assert_consistent( State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}), good_v, ) +def test_secret_not_in_state(): + secret = Secret("secret:foo", {"a": "b"}) + assert_inconsistent( + State(), + _Event("secret_changed", secret=secret), + _CharmSpec(MyCharm, {}), + ) + assert_consistent( + State(secrets=[secret]), + _Event("secret_changed", secret=secret), + _CharmSpec(MyCharm, {}), + ) + + def test_peer_relation_consistency(): assert_inconsistent( State(relations=[Relation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) assert_consistent( State(relations=[PeerRelation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) @@ -292,7 +322,7 @@ def test_peer_relation_consistency(): def test_duplicate_endpoints_inconsistent(): assert_inconsistent( State(), - Event("bar"), + _Event("bar"), _CharmSpec( MyCharm, { @@ -306,7 +336,7 @@ def test_duplicate_endpoints_inconsistent(): def test_sub_relation_consistency(): assert_inconsistent( State(relations=[Relation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec( MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, @@ -315,7 +345,7 @@ def test_sub_relation_consistency(): assert_consistent( State(relations=[SubordinateRelation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec( MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, @@ -326,25 +356,30 @@ def test_sub_relation_consistency(): def test_relation_sub_inconsistent(): assert_inconsistent( State(relations=[SubordinateRelation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) -def test_dupe_containers_inconsistent(): +def test_relation_not_in_state(): + relation = Relation("foo") assert_inconsistent( - State(containers=[Container("foo"), Container("foo")]), - Event("bar"), - _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + State(), + _Event("foo_relation_changed", relation=relation), + _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), + ) + assert_consistent( + State(relations=[relation]), + _Event("foo_relation_changed", relation=relation), + _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) -def test_container_pebble_evt_consistent(): - container = Container("foo-bar-baz") - assert_consistent( - State(containers=[container]), - container.pebble_ready_event, - _CharmSpec(MyCharm, {"containers": {"foo-bar-baz": {}}}), +def test_dupe_containers_inconsistent(): + assert_inconsistent( + State(containers=[Container("foo"), Container("foo")]), + _Event("bar"), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -386,7 +421,7 @@ def test_action_name(): ) assert_inconsistent( State(), - Event("box_action", action=action), + _Event("box_action", action=action), _CharmSpec(MyCharm, meta={}, actions={"foo": {}}), ) @@ -425,9 +460,9 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + relations=[Relation("foo", id=1), Relation("bar", id=1)] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -440,17 +475,17 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + relations=[Relation("foo", id=1), Relation("bar", id=1)] ), - Event("start"), + _Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=2)] + relations=[Relation("foo", id=1), Relation("bar", id=2)] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -464,19 +499,12 @@ def test_storage_event(): storage = Storage("foo") assert_inconsistent( State(storage=[storage]), - Event("foo-storage-attached"), + _Event("foo-storage-attached"), _CharmSpec(MyCharm, meta={"name": "rupert"}), ) assert_inconsistent( State(storage=[storage]), - Event("foo-storage-attached"), - _CharmSpec( - MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} - ), - ) - assert_consistent( - State(storage=[storage]), - storage.attached_event, + _Event("foo-storage-attached"), _CharmSpec( MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} ), @@ -489,19 +517,19 @@ def test_storage_states(): assert_inconsistent( State(storage=[storage1, storage2]), - Event("start"), + _Event("start"), _CharmSpec(MyCharm, meta={"name": "everett"}), ) assert_consistent( State(storage=[storage1, dataclasses.replace(storage2, index=2)]), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} ), ) assert_consistent( State(storage=[storage1, dataclasses.replace(storage2, name="marx")]), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -515,11 +543,31 @@ def test_storage_states(): ) +def test_storage_not_in_state(): + storage = Storage("foo") + assert_inconsistent( + State(), + _Event("foo_storage_attached", storage=storage), + _CharmSpec( + MyCharm, + meta={"name": "sam", "storage": {"foo": {"type": "filesystem"}}}, + ), + ) + assert_consistent( + State(storage=[storage]), + _Event("foo_storage_attached", storage=storage), + _CharmSpec( + MyCharm, + meta={"name": "sam", "storage": {"foo": {"type": "filesystem"}}}, + ), + ) + + def test_resource_states(): # happy path assert_consistent( State(resources={"foo": "/foo/bar.yaml"}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, @@ -529,7 +577,7 @@ def test_resource_states(): # no resources in state but some in meta: OK. Not realistic wrt juju but fine for testing assert_consistent( State(), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, @@ -539,7 +587,7 @@ def test_resource_states(): # resource not defined in meta assert_inconsistent( State(resources={"bar": "/foo/bar.yaml"}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, @@ -548,7 +596,7 @@ def test_resource_states(): assert_inconsistent( State(resources={"bar": "/foo/bar.yaml"}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman"}, @@ -559,7 +607,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( State(networks={"foo": Network.default()}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "wonky"}, @@ -568,7 +616,7 @@ def test_networks_consistency(): assert_inconsistent( State(networks={"foo": Network.default()}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -581,7 +629,7 @@ def test_networks_consistency(): assert_consistent( State(networks={"foo": Network.default()}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -637,7 +685,7 @@ def test_storedstate_consistency(): StoredState("OtherCharmLib", content={"foo": (1, 2, 3)}), ] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -652,7 +700,7 @@ def test_storedstate_consistency(): StoredState(None, "_stored", content={"foo": "bar"}), ] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -662,7 +710,7 @@ def test_storedstate_consistency(): ) assert_inconsistent( State(stored_state=[StoredState(None, content={"secret": Secret("foo", {})})]), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ diff --git a/tests/test_context.py b/tests/test_context.py index 7d2b795c..d6995efc 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,8 +3,8 @@ import pytest from ops import CharmBase -from scenario import Action, Context, Event, State -from scenario.state import next_action_id +from scenario import Action, Context, State +from scenario.state import _Event, next_action_id class MyCharm(CharmBase): @@ -17,14 +17,14 @@ def test_run(): with patch.object(ctx, "_run") as p: ctx._output_state = "foo" # would normally be set within the _run call scope - output = ctx.run("start", state) + output = ctx.run(ctx.on.start(), state) assert output == "foo" assert p.called e = p.call_args.kwargs["event"] s = p.call_args.kwargs["state"] - assert isinstance(e, Event) + assert isinstance(e, _Event) assert e.name == "start" assert s is state @@ -38,7 +38,8 @@ def test_run_action(): ctx._output_state = ( "foo" # would normally be set within the _run_action call scope ) - output = ctx.run_action("do-foo", state) + action = Action("do-foo") + output = ctx.run_action(action, state) assert output.state == "foo" assert p.called @@ -54,8 +55,7 @@ def test_run_action(): @pytest.mark.parametrize("app_name", ("foo", "bar", "george")) @pytest.mark.parametrize("unit_id", (1, 2, 42)) def test_app_name(app_name, unit_id): - with Context( - MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id - ).manager("start", State()) as mgr: + ctx = Context(MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id) + with ctx.manager(ctx.on.start(), State()) as mgr: assert mgr.charm.app.name == app_name assert mgr.charm.unit.name == f"{app_name}/{unit_id}" diff --git a/tests/test_context_on.py b/tests/test_context_on.py new file mode 100644 index 00000000..be8c70b5 --- /dev/null +++ b/tests/test_context_on.py @@ -0,0 +1,338 @@ +import copy + +import ops +import pytest + +import scenario + +META = { + "name": "context-charm", + "containers": { + "bar": {}, + }, + "requires": { + "baz": { + "interface": "charmlink", + } + }, + "storage": { + "foo": { + "type": "filesystem", + } + }, +} +ACTIONS = { + "act": { + "params": { + "param": { + "description": "some parameter", + "type": "string", + "default": "", + } + } + }, +} + + +class ContextCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.observed = [] + for event in self.on.events().values(): + framework.observe(event, self._on_event) + + def _on_event(self, event): + self.observed.append(event) + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("install", ops.InstallEvent), + ("start", ops.StartEvent), + ("stop", ops.StopEvent), + ("remove", ops.RemoveEvent), + ("update_status", ops.UpdateStatusEvent), + ("config_changed", ops.ConfigChangedEvent), + ("upgrade_charm", ops.UpgradeCharmEvent), + ("pre_series_upgrade", ops.PreSeriesUpgradeEvent), + ("post_series_upgrade", ops.PostSeriesUpgradeEvent), + ("leader_elected", ops.LeaderElectedEvent), + ], +) +def test_simple_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + # These look like: + # ctx.run(ctx.on.install(), state) + with ctx.manager(getattr(ctx.on, event_name)(), scenario.State()) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + assert isinstance(mgr.charm.observed[0], event_kind) + + +@pytest.mark.parametrize("as_kwarg", [True, False]) +@pytest.mark.parametrize( + "event_name,event_kind,owner", + [ + ("secret_changed", ops.SecretChangedEvent, None), + ("secret_rotate", ops.SecretRotateEvent, "app"), + ], +) +def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + secret = scenario.Secret("secret:123", {0: {"password": "xxxx"}}, owner=owner) + state_in = scenario.State(secrets=[secret]) + # These look like: + # ctx.run(ctx.on.secret_changed(secret=secret), state) + # The secret must always be passed because the same event name is used for + # all secrets. + if as_kwarg: + args = () + kwargs = {"secret": secret} + else: + args = (secret,) + kwargs = {} + with ctx.manager(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.secret.id == secret.id + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("secret_expired", ops.SecretExpiredEvent), + ("secret_remove", ops.SecretRemoveEvent), + ], +) +def test_revision_secret_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + secret = scenario.Secret( + "secret:123", + {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + owner="app", + ) + state_in = scenario.State(secrets=[secret]) + # These look like: + # ctx.run(ctx.on.secret_expired(secret=secret, revision=revision), state) + # The secret and revision must always be passed because the same event name + # is used for all secrets. + with ctx.manager(getattr(ctx.on, event_name)(secret, revision=42), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.secret.id == secret.id + assert event.revision == 42 + + +@pytest.mark.parametrize("event_name", ["secret_expired", "secret_remove"]) +def test_revision_secret_events_as_positional_arg(event_name): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + secret = scenario.Secret( + "secret:123", {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, owner=None + ) + state_in = scenario.State(secrets=[secret]) + with pytest.raises(TypeError): + ctx.run(getattr(ctx.on, event_name)(secret, 42), state_in) + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("storage_attached", ops.StorageAttachedEvent), + ("storage_detaching", ops.StorageDetachingEvent), + ], +) +def test_storage_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + storage = scenario.Storage("foo") + state_in = scenario.State(storage=[storage]) + # These look like: + # ctx.run(ctx.on.storage_attached(storage), state) + with ctx.manager(getattr(ctx.on, event_name)(storage), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.storage.name == storage.name + assert event.storage.index == storage.index + + +def test_action_event_no_params(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + # These look like: + # ctx.run_action(ctx.on.action(action), state) + action = scenario.Action("act") + with ctx.action_manager(action, scenario.State()) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.ActionEvent) + + +def test_action_event_with_params(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + action = scenario.Action("act", {"param": "hello"}) + # These look like: + # ctx.run_action(ctx.on.action(action=action), state) + # So that any parameters can be included and the ID can be customised. + with ctx.action_manager(action, scenario.State()) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.ActionEvent) + assert event.id == action.id + assert event.params["param"] == action.params["param"] + + +def test_pebble_ready_event(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + container = scenario.Container("bar", can_connect=True) + state_in = scenario.State(containers=[container]) + # These look like: + # ctx.run(ctx.on.pebble_ready(container), state) + with ctx.manager(ctx.on.pebble_ready(container), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.PebbleReadyEvent) + assert event.workload.name == container.name + + +@pytest.mark.parametrize("as_kwarg", [True, False]) +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("relation_created", ops.RelationCreatedEvent), + ("relation_broken", ops.RelationBrokenEvent), + ], +) +def test_relation_app_events(as_kwarg, event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz") + state_in = scenario.State(relations=[relation]) + # These look like: + # ctx.run(ctx.on.relation_created(relation), state) + if as_kwarg: + args = () + kwargs = {"relation": relation} + else: + args = (relation,) + kwargs = {} + with ctx.manager(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit is None + + +def test_relation_complex_name(): + meta = copy.deepcopy(META) + meta["requires"]["foo-bar-baz"] = {"interface": "another-one"} + ctx = scenario.Context(ContextCharm, meta=meta, actions=ACTIONS) + relation = scenario.Relation("foo-bar-baz") + state_in = scenario.State(relations=[relation]) + with ctx.manager(ctx.on.relation_created(relation), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + event = mgr.charm.observed[0] + assert isinstance(event, ops.RelationCreatedEvent) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit is None + + +@pytest.mark.parametrize("event_name", ["relation_created", "relation_broken"]) +def test_relation_events_as_positional_arg(event_name): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz") + state_in = scenario.State(relations=[relation]) + with pytest.raises(TypeError): + ctx.run(getattr(ctx.on, event_name)(relation, 0), state_in) + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("relation_joined", ops.RelationJoinedEvent), + ("relation_changed", ops.RelationChangedEvent), + ], +) +def test_relation_unit_events_default_unit(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz", remote_units_data={1: {"x": "y"}}) + state_in = scenario.State(relations=[relation]) + # These look like: + # ctx.run(ctx.on.baz_relation_changed, state) + # The unit is chosen automatically. + with ctx.manager(getattr(ctx.on, event_name)(relation), state_in) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit.name == "remote/1" + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("relation_joined", ops.RelationJoinedEvent), + ("relation_changed", ops.RelationChangedEvent), + ], +) +def test_relation_unit_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation( + "baz", remote_units_data={1: {"x": "y"}, 2: {"x": "z"}} + ) + state_in = scenario.State(relations=[relation]) + # These look like: + # ctx.run(ctx.on.baz_relation_changed(unit=unit_ordinal), state) + with ctx.manager( + getattr(ctx.on, event_name)(relation, remote_unit=2), state_in + ) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit.name == "remote/2" + + +def test_relation_departed_event(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz") + state_in = scenario.State(relations=[relation]) + # These look like: + # ctx.run(ctx.on.baz_relation_departed(unit=unit_ordinal, departing_unit=unit_ordinal), state) + with ctx.manager( + ctx.on.relation_departed(relation, remote_unit=2, departing_unit=1), state_in + ) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.RelationDepartedEvent) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit.name == "remote/2" + assert event.departing_unit.name == "remote/1" diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 30cc9d1e..6256885c 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,8 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, Event, State -from tests.helpers import trigger +from scenario.state import Action, State, _Event @pytest.fixture(scope="function") @@ -65,13 +64,6 @@ def test_cannot_run_action(mycharm): ctx.run(action, state=State()) -def test_cannot_run_action_name(mycharm): - ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - action = Action("foo") - with pytest.raises(InvalidEventError): - ctx.run(action.event.name, state=State()) - - def test_cannot_run_action_event(mycharm): ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) action = Action("foo") diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py deleted file mode 100644 index 1c6f07cd..00000000 --- a/tests/test_e2e/test_custom_event_triggers.py +++ /dev/null @@ -1,146 +0,0 @@ -import os -from unittest.mock import MagicMock - -import pytest -from ops.charm import CharmBase, CharmEvents -from ops.framework import EventBase, EventSource, Object - -from scenario import State -from scenario.ops_main_mock import NoObserverError -from scenario.runtime import InconsistentScenarioError -from tests.helpers import trigger - - -def test_custom_event_emitted(): - class FooEvent(EventBase): - pass - - class MyCharmEvents(CharmEvents): - foo = EventSource(FooEvent) - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - on = MyCharmEvents() - _foo_called = 0 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.framework.observe(self.on.foo, self._on_foo) - self.framework.observe(self.on.start, self._on_start) - - def _on_foo(self, e): - MyCharm._foo_called += 1 - - def _on_start(self, e): - self.on.foo.emit() - - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called == 1 - - trigger(State(), "start", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called == 2 - - -def test_funky_named_event_emitted(): - class FooRelationChangedEvent(EventBase): - pass - - class MyCharmEvents(CharmEvents): - foo_relation_changed = EventSource(FooRelationChangedEvent) - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - on = MyCharmEvents() - _foo_called = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.framework.observe(self.on.foo_relation_changed, self._on_foo) - - def _on_foo(self, e): - MyCharm._foo_called = True - - # we called our custom event like a builtin one. Trouble! - with pytest.raises(InconsistentScenarioError): - trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) - - assert not MyCharm._foo_called - - os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "1" - trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called - os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS") - - -def test_child_object_event_emitted_no_path_raises(): - class FooEvent(EventBase): - pass - - class MyObjEvents(CharmEvents): - foo = EventSource(FooEvent) - - class MyObject(Object): - my_on = MyObjEvents() - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - _foo_called = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.obj = MyObject(self, "child") - self.framework.observe(self.obj.my_on.foo, self._on_foo) - - def _on_foo(self, e): - MyCharm._foo_called = True - - with pytest.raises(NoObserverError): - # this will fail because "foo" isn't registered on MyCharm but on MyCharm.foo - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called - - # workaround: we can use pre_event to have Scenario set up the simulation for us and run our - # test code before it eventually fails. pre_event gets called with the set-up charm instance. - def pre_event(charm: MyCharm): - event_mock = MagicMock() - charm._on_foo(event_mock) - assert charm.unit.name == "mycharm/0" - - # make sure you only filter out NoObserverError, else if pre_event raises, - # they will also be caught while you want them to bubble up. - with pytest.raises(NoObserverError): - trigger( - State(), - "rubbish", # you can literally put anything here - MyCharm, - pre_event=pre_event, - meta=MyCharm.META, - ) - assert MyCharm._foo_called - - -def test_child_object_event(): - class FooEvent(EventBase): - pass - - class MyObjEvents(CharmEvents): - foo = EventSource(FooEvent) - - class MyObject(Object): - my_on = MyObjEvents() - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - _foo_called = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.obj = MyObject(self, "child") - self.framework.observe(self.obj.my_on.foo, self._on_foo) - - def _on_foo(self, e): - MyCharm._foo_called = True - - trigger(State(), "obj.my_on.foo", MyCharm, meta=MyCharm.META) - - assert MyCharm._foo_called diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index a96100bc..8645c77b 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -12,7 +12,7 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Container, DeferredEvent, Notice, Relation, State, deferred +from scenario.state import Container, Notice, Relation, State, _Event, deferred from tests.helpers import trigger CHARM_CALLED = 0 @@ -79,7 +79,9 @@ def test_deferred_relation_event_without_relation_raises(mycharm): def test_deferred_relation_evt(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") - evt1 = rel.changed_event.deferred(handler=mycharm._on_event) + evt1 = _Event("foo_relation_changed", relation=rel).deferred( + handler=mycharm._on_event + ) evt2 = deferred( event="foo_relation_changed", handler=mycharm._on_event, @@ -91,7 +93,7 @@ def test_deferred_relation_evt(mycharm): def test_deferred_workload_evt(mycharm): ctr = Container("foo") - evt1 = ctr.pebble_ready_event.deferred(handler=mycharm._on_event) + evt1 = _Event("foo_pebble_ready", container=ctr).deferred(handler=mycharm._on_event) evt2 = deferred(event="foo_pebble_ready", handler=mycharm._on_event, container=ctr) assert asdict(evt2) == asdict(evt1) @@ -145,13 +147,16 @@ def test_deferred_relation_event(mycharm): def test_deferred_relation_event_from_relation(mycharm): + ctx = Context(mycharm, meta=mycharm.META) mycharm.defer_next = 2 rel = Relation(endpoint="foo", remote_app_name="remote") out = trigger( State( relations=[rel], deferred=[ - rel.changed_event(remote_unit_id=1).deferred(handler=mycharm._on_event) + ctx.on.relation_changed(rel, remote_unit=1).deferred( + handler=mycharm._on_event + ) ], ), "start", @@ -186,7 +191,11 @@ def test_deferred_workload_event(mycharm): out = trigger( State( containers=[ctr], - deferred=[ctr.pebble_ready_event.deferred(handler=mycharm._on_event)], + deferred=[ + _Event("foo_pebble_ready", container=ctr).deferred( + handler=mycharm._on_event + ) + ], ), "start", mycharm, @@ -210,10 +219,10 @@ def test_defer_reemit_lifecycle_event(mycharm): ctx = Context(mycharm, meta=mycharm.META, capture_deferred_events=True) mycharm.defer_next = 1 - state_1 = ctx.run("update-status", State()) + state_1 = ctx.run(ctx.on.update_status(), State()) mycharm.defer_next = 0 - state_2 = ctx.run("start", state_1) + state_2 = ctx.run(ctx.on.start(), state_1) assert [type(e).__name__ for e in ctx.emitted_events] == [ "UpdateStatusEvent", @@ -229,10 +238,10 @@ def test_defer_reemit_relation_event(mycharm): rel = Relation("foo") mycharm.defer_next = 1 - state_1 = ctx.run(rel.created_event, State(relations=[rel])) + state_1 = ctx.run(ctx.on.relation_created(rel), State(relations=[rel])) mycharm.defer_next = 0 - state_2 = ctx.run("start", state_1) + state_2 = ctx.run(ctx.on.start(), state_1) assert [type(e).__name__ for e in ctx.emitted_events] == [ "RelationCreatedEvent", diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py index f30fc65a..20997421 100644 --- a/tests/test_e2e/test_event.py +++ b/tests/test_e2e/test_event.py @@ -3,7 +3,7 @@ from ops import CharmBase, StartEvent, UpdateStatusEvent from scenario import Context -from scenario.state import Event, State, _CharmSpec, _EventType +from scenario.state import State, _CharmSpec, _Event, _EventType @pytest.mark.parametrize( @@ -29,7 +29,7 @@ ), ) def test_event_type(evt, expected_type): - event = Event(evt) + event = _Event(evt) assert event._path.type is expected_type assert event._is_relation_event is (expected_type is _EventType.relation) @@ -63,7 +63,7 @@ class MyCharm(CharmBase): META = {"name": "joop"} ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True) - ctx.run("update-status", State()) + ctx.run(ctx.on.update_status(), State()) assert len(ctx.emitted_events) == 4 assert list(map(type, ctx.emitted_events)) == [ ops.UpdateStatusEvent, @@ -86,7 +86,9 @@ def _foo(self, e): capture_deferred_events=True, capture_framework_events=True, ) - ctx.run("start", State(deferred=[Event("update-status").deferred(MyCharm._foo)])) + ctx.run( + ctx.on.start(), State(deferred=[_Event("update-status").deferred(MyCharm._foo)]) + ) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ diff --git a/tests/test_e2e/test_event_bind.py b/tests/test_e2e/test_event_bind.py deleted file mode 100644 index 4878e6ac..00000000 --- a/tests/test_e2e/test_event_bind.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest - -from scenario import Container, Event, Relation, Secret, State -from scenario.state import BindFailedError - - -def test_bind_relation(): - event = Event("foo-relation-changed") - foo_relation = Relation("foo") - state = State(relations=[foo_relation]) - assert event.bind(state).relation is foo_relation - - -def test_bind_relation_complex_name(): - event = Event("foo-bar-baz-relation-changed") - foo_relation = Relation("foo_bar_baz") - state = State(relations=[foo_relation]) - assert event.bind(state).relation is foo_relation - - -def test_bind_relation_notfound(): - event = Event("foo-relation-changed") - state = State(relations=[]) - with pytest.raises(BindFailedError): - event.bind(state) - - -def test_bind_relation_toomany(caplog): - event = Event("foo-relation-changed") - foo_relation = Relation("foo") - foo_relation1 = Relation("foo") - state = State(relations=[foo_relation, foo_relation1]) - event.bind(state) - assert "too many relations" in caplog.text - - -def test_bind_secret(): - event = Event("secret-changed") - secret = Secret("foo", {"a": "b"}) - state = State(secrets=[secret]) - assert event.bind(state).secret is secret - - -def test_bind_secret_notfound(): - event = Event("secret-changed") - state = State(secrets=[]) - with pytest.raises(BindFailedError): - event.bind(state) - - -def test_bind_container(): - event = Event("foo-pebble-ready") - container = Container("foo") - state = State(containers=[container]) - assert event.bind(state).container is container - - -def test_bind_container_notfound(): - event = Event("foo-pebble-ready") - state = State(containers=[]) - with pytest.raises(BindFailedError): - event.bind(state) diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index 5f58a973..1efd1e70 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -5,7 +5,6 @@ from scenario import Context from scenario.state import JujuLogLine, State -from tests.helpers import trigger logger = logging.getLogger("testing logger") @@ -31,7 +30,7 @@ def _on_event(self, event): def test_juju_log(mycharm): ctx = Context(mycharm, meta=mycharm.META) - ctx.run("start", State()) + ctx.run(ctx.on.start(), State()) assert ctx.juju_log[-2] == JujuLogLine( level="DEBUG", message="Emitting Juju event start." ) diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 28cbe516..66d39f82 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -31,7 +31,7 @@ def _on_event(self, e): def test_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, "start", State()) as manager: + with _EventManager(ctx, ctx.on.start(), State()) as manager: assert isinstance(manager.charm, mycharm) assert not manager.output state_out = manager.run() @@ -43,7 +43,7 @@ def test_manager(mycharm): def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, "start", State()) as manager: + with _EventManager(ctx, ctx.on.start(), State()) as manager: assert isinstance(manager.charm, mycharm) # do not call .run() @@ -55,7 +55,7 @@ def test_manager_implicit(mycharm): def test_manager_reemit_fails(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, "start", State()) as manager: + with _EventManager(ctx, ctx.on.start(), State()) as manager: manager.run() with pytest.raises(AlreadyEmittedError): manager.run() @@ -65,7 +65,7 @@ def test_manager_reemit_fails(mycharm): def test_context_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with ctx.manager("start", State()) as manager: + with ctx.manager(ctx.on.start(), State()) as manager: state_out = manager.run() assert isinstance(state_out, State) assert ctx.emitted_events[0].handle.kind == "start" diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 28a4133c..5c08b949 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -41,7 +41,7 @@ def test_ip_get(mycharm): ) with ctx.manager( - "update_status", + ctx.on.update_status(), State( relations=[ Relation( @@ -78,7 +78,7 @@ def test_no_sub_binding(mycharm): ) with ctx.manager( - "update_status", + ctx.on.update_status(), State( relations=[ SubordinateRelation("bar"), @@ -103,7 +103,7 @@ def test_no_relation_error(mycharm): ) with ctx.manager( - "update_status", + ctx.on.update_status(), State( relations=[ Relation( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 865ffb30..bf83c6c6 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -130,7 +130,7 @@ def callback(self: CharmBase): charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, ) - with ctx.manager("start", state=state) as mgr: + with ctx.manager(ctx.on.start(), state=state) as mgr: out = mgr.run() callback(mgr.charm) @@ -224,7 +224,7 @@ def callback(self: CharmBase): State(containers=[container]), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, - event=container.pebble_ready_event, + event="pebble_ready", post_event=callback, ) @@ -291,7 +291,7 @@ def _on_ready(self, event): State(containers=[container]), charm_type=PlanCharm, meta={"name": "foo", "containers": {"foo": {}}}, - event=container.pebble_ready_event, + event="pebble_ready", ) serv = lambda name, obj: pebble.Service(name, raw=obj) @@ -318,9 +318,8 @@ def test_exec_wait_error(charm_cls): ] ) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - "start", state - ) as mgr: + ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + with ctx.manager(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) with pytest.raises(ExecError): @@ -341,9 +340,8 @@ def test_exec_wait_output(charm_cls): ] ) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - "start", state - ) as mgr: + ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + with ctx.manager(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) out, err = proc.wait_output() @@ -362,9 +360,8 @@ def test_exec_wait_output_error(charm_cls): ] ) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - "start", state - ) as mgr: + ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + with ctx.manager(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) with pytest.raises(ExecError): diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index dc10b3a9..13502971 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -27,7 +27,7 @@ def ctx(): def test_open_port(ctx): - out = ctx.run("start", State()) + out = ctx.run(ctx.on.start(), State()) port = out.opened_ports.pop() assert port.protocol == "tcp" @@ -35,5 +35,5 @@ def test_open_port(ctx): def test_close_port(ctx): - out = ctx.run("stop", State(opened_ports=[Port("tcp", 42)])) + out = ctx.run(ctx.on.stop(), State(opened_ports=[Port("tcp", 42)])) assert not out.opened_ports diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 57ad0769..f3447cbe 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -5,6 +5,8 @@ CharmBase, CharmEvents, CollectStatusEvent, + RelationBrokenEvent, + RelationCreatedEvent, RelationDepartedEvent, RelationEvent, ) @@ -80,6 +82,7 @@ def pre_event(charm: CharmBase): }, }, config={"options": {"foo": {"type": "string"}}}, + pre_event=pre_event, ) @@ -97,7 +100,7 @@ def test_relation_events(mycharm, evt_name): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -141,7 +144,7 @@ def callback(charm: CharmBase, e): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -153,8 +156,14 @@ def callback(charm: CharmBase, e): @pytest.mark.parametrize( - "evt_name", - ("changed", "broken", "departed", "joined", "created"), + "evt_name,has_unit", + [ + ("changed", True), + ("broken", False), + ("departed", True), + ("joined", True), + ("created", False), + ], ) @pytest.mark.parametrize( "remote_app_name", @@ -164,7 +173,9 @@ def callback(charm: CharmBase, e): "remote_unit_id", (0, 1), ) -def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_id): +def test_relation_events_attrs( + mycharm, evt_name, has_unit, remote_app_name, remote_unit_id +): relation = Relation( endpoint="foo", interface="foo", remote_app_name=remote_app_name ) @@ -174,20 +185,15 @@ def callback(charm: CharmBase, event): return assert event.app - assert event.unit + if not isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)): + assert event.unit if isinstance(event, RelationDepartedEvent): assert event.departing_unit mycharm._call = callback - trigger( - State( - relations=[ - relation, - ], - ), - getattr(relation, f"{evt_name}_event")(remote_unit_id=remote_unit_id), - mycharm, + ctx = Context( + charm_type=mycharm, meta={ "name": "local", "requires": { @@ -195,6 +201,12 @@ def callback(charm: CharmBase, event): }, }, ) + state = State(relations=[relation]) + kwargs = {} + if has_unit: + kwargs["remote_unit"] = remote_unit_id + event = getattr(ctx.on, f"relation_{evt_name}")(relation, **kwargs) + ctx.run(event, state=state) @pytest.mark.parametrize( @@ -218,7 +230,11 @@ def callback(charm: CharmBase, event): return assert event.app # that's always present - assert event.unit + # .unit is always None for created and broken. + if isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)): + assert event.unit is None + else: + assert event.unit assert (evt_name == "departed") is bool(getattr(event, "departing_unit", False)) mycharm._call = callback @@ -229,7 +245,7 @@ def callback(charm: CharmBase, event): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -239,9 +255,11 @@ def callback(charm: CharmBase, event): }, ) - assert ( - "remote unit ID unset, and multiple remote unit IDs are present" in caplog.text - ) + if evt_name not in ("created", "broken"): + assert ( + "remote unit ID unset, and multiple remote unit IDs are present" + in caplog.text + ) def test_relation_default_unit_data_regular(): @@ -287,7 +305,7 @@ def callback(charm: CharmBase, event): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -297,7 +315,8 @@ def callback(charm: CharmBase, event): }, ) - assert "remote unit ID unset; no remote unit data present" in caplog.text + if evt_name not in ("created", "broken"): + assert "remote unit ID unset; no remote unit data present" in caplog.text @pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) @@ -337,7 +356,7 @@ def test_relation_event_trigger(relation, evt_name, mycharm): } state = trigger( State(relations=[relation]), - getattr(relation, evt_name + "_event"), + f"relation_{evt_name}", mycharm, meta=meta, ) @@ -370,7 +389,7 @@ def post_event(charm: CharmBase): trigger( State(relations=[sub1, sub2]), - "update-status", + "update_status", mycharm, meta=meta, post_event=post_event, @@ -394,9 +413,10 @@ def test_relation_ids(): def test_broken_relation_not_in_model_relations(mycharm): rel = Relation("foo") - with Context( + ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} - ).manager(rel.broken_event, state=State(relations=[rel])) as mgr: + ) + with ctx.manager(ctx.on.relation_broken(rel), state=State(relations=[rel])) as mgr: charm = mgr.charm assert charm.model.get_relation("foo") is None diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index 10582d82..0656b80c 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, EventSource, Framework, Object from scenario.ops_main_mock import NoObserverError -from scenario.state import Container, Event, State, _CharmSpec +from scenario.state import Container, State, _CharmSpec, _Event from tests.helpers import trigger @@ -46,7 +46,7 @@ def _on_event(self, e): @pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_raises(mycharm, evt_name): - with pytest.raises(NoObserverError): + with pytest.raises(AttributeError): if evt_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true" # else it will whine about the container not being in state and meta; @@ -59,14 +59,15 @@ def test_rubbish_event_raises(mycharm, evt_name): @pytest.mark.parametrize("evt_name", ("qux",)) -def test_custom_events_pass(mycharm, evt_name): - trigger(State(), evt_name, mycharm, meta={"name": "foo"}) +def test_custom_events_fail(mycharm, evt_name): + with pytest.raises(AttributeError): + trigger(State(), evt_name, mycharm, meta={"name": "foo"}) # cfr: https://github.com/PietroPasotti/ops-scenario/pull/11#discussion_r1101694961 @pytest.mark.parametrize("evt_name", ("sub",)) def test_custom_events_sub_raise(mycharm, evt_name): - with pytest.raises(RuntimeError): + with pytest.raises(AttributeError): trigger(State(), evt_name, mycharm, meta={"name": "foo"}) @@ -86,4 +87,4 @@ def test_is_custom_event(mycharm, evt_name, expected): spec = _CharmSpec( charm_type=mycharm, meta={"name": "mycharm", "requires": {"foo": {}}} ) - assert Event(evt_name)._is_builtin_event(spec) is expected + assert _Event(evt_name)._is_builtin_event(spec) is expected diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 4fa38363..164b250b 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -34,9 +34,8 @@ def _on_event(self, event): def test_get_secret_no_secret(mycharm): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", State() - ) as mgr: + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager(ctx.on.update_status(), State()) as mgr: with pytest.raises(SecretNotFoundError): assert mgr.charm.model.get_secret(id="foo") with pytest.raises(SecretNotFoundError): @@ -44,17 +43,19 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): - with Context(mycharm, meta={"name": "local"}).manager( + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), - event="update_status", + event=ctx.on.update_status(), ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_get_refresh(mycharm, owner): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( secrets=[ Secret( @@ -74,8 +75,9 @@ def test_get_secret_get_refresh(mycharm, owner): @pytest.mark.parametrize("app", (True, False)) def test_get_secret_nonowner_peek_update(mycharm, app): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( leader=app, secrets=[ @@ -100,8 +102,9 @@ def test_get_secret_nonowner_peek_update(mycharm, app): @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_owner_peek_update(mycharm, owner): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( secrets=[ Secret( @@ -123,36 +126,48 @@ def test_get_secret_owner_peek_update(mycharm, owner): @pytest.mark.parametrize("owner", ("app", "unit")) def test_secret_changed_owner_evt_fails(mycharm, owner): + ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + owner=owner, + ) with pytest.raises(ValueError): - _ = Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - owner=owner, - ).changed_event - - -@pytest.mark.parametrize("evt_prefix", ("rotate", "expired", "remove")) -def test_consumer_events_failures(mycharm, evt_prefix): + _ = ctx.on.secret_changed(secret) + + +@pytest.mark.parametrize( + "evt_suffix,revision", + [ + ("rotate", None), + ("expired", 1), + ("remove", 1), + ], +) +def test_consumer_events_failures(mycharm, evt_suffix, revision): + ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + ) + kwargs = {"secret": secret} + if revision is not None: + kwargs["revision"] = revision with pytest.raises(ValueError): - _ = getattr( - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - ), - evt_prefix + "_event", - ) + _ = getattr(ctx.on, f"secret_{evt_suffix}")(**kwargs) @pytest.mark.parametrize("app", (True, False)) def test_add(mycharm, app): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State(leader=app), ) as mgr: charm = mgr.charm @@ -171,8 +186,9 @@ def test_set_legacy_behaviour(mycharm): # in juju < 3.1.7, secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}, juju_version="3.1.6").manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6") + with ctx.manager( + ctx.on.update_status(), State(), ) as mgr: charm = mgr.charm @@ -212,8 +228,9 @@ def test_set_legacy_behaviour(mycharm): def test_set(mycharm): rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State(), ) as mgr: charm = mgr.charm @@ -243,8 +260,9 @@ def test_set(mycharm): def test_set_juju33(mycharm): rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}, juju_version="3.3.1").manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1") + with ctx.manager( + ctx.on.update_status(), State(), ) as mgr: charm = mgr.charm @@ -271,8 +289,9 @@ def test_set_juju33(mycharm): @pytest.mark.parametrize("app", (True, False)) def test_meta(mycharm, app): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( leader=True, secrets=[ @@ -310,8 +329,9 @@ def test_secret_permission_model(mycharm, leader, owner): or (owner == "unit") ) - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( leader=leader, secrets=[ @@ -360,10 +380,11 @@ def test_secret_permission_model(mycharm, leader, owner): @pytest.mark.parametrize("app", (True, False)) def test_grant(mycharm, app): - with Context( + ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} - ).manager( - "update_status", + ) + with ctx.manager( + ctx.on.update_status(), State( relations=[Relation("foo", "remote")], secrets=[ @@ -394,8 +415,9 @@ def test_grant(mycharm, app): def test_update_metadata(mycharm): exp = datetime.datetime(2050, 12, 12) - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( secrets=[ Secret( @@ -439,10 +461,10 @@ def _on_start(self, _): secret.grant(self.model.relations["bar"][0]) state = State(leader=leader, relations=[Relation("bar")]) - context = Context( + ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) - context.run("start", state) + ctx.run(ctx.on.start(), state) def test_grant_nonowner(mycharm): @@ -482,7 +504,7 @@ class GrantingCharm(CharmBase): def __init__(self, *args): super().__init__(*args) - context = Context( + ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) relation_remote_app = "remote_secret_desirerer" @@ -497,7 +519,7 @@ def __init__(self, *args): ], ) - with context.manager("start", state) as mgr: + with ctx.manager(ctx.on.start(), state) as mgr: charm = mgr.charm secret = charm.app.add_secret({"foo": "bar"}, label="mylabel") bar_relation = charm.model.relations["bar"][0] @@ -508,7 +530,7 @@ def __init__(self, *args): scenario_secret = mgr.output.secrets[0] assert relation_remote_app in scenario_secret.remote_grants[relation_id] - with context.manager("start", mgr.output) as mgr: + with ctx.manager(ctx.on.start(), mgr.output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) @@ -516,7 +538,7 @@ def __init__(self, *args): scenario_secret = mgr.output.secrets[0] assert scenario_secret.remote_grants == {} - with context.manager("start", mgr.output) as mgr: + with ctx.manager(ctx.on.start(), mgr.output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index e587b406..6b3bc0c5 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -61,7 +61,7 @@ def _on_update_status(self, _): meta={"name": "local"}, ) - out = ctx.run("update_status", State(leader=True)) + out = ctx.run(ctx.on.update_status(), State(leader=True)) assert out.unit_status == WaitingStatus("3") assert ctx.unit_status_history == [ @@ -94,7 +94,7 @@ def _on_update_status(self, _): ) out = ctx.run( - "update_status", + ctx.on.update_status(), State( leader=True, unit_status=ActiveStatus("foo"), @@ -131,9 +131,9 @@ def _on_update_status(self, _): meta={"name": "local"}, ) - out = ctx.run("install", State(leader=True)) - out = ctx.run("start", out) - out = ctx.run("update_status", out) + out = ctx.run(ctx.on.install(), State(leader=True)) + out = ctx.run(ctx.on.start(), out) + out = ctx.run(ctx.on.update_status(), out) assert ctx.workload_version_history == ["1", "1.1"] assert out.workload_version == "1.2" diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index 87aa9370..b62288bb 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -23,13 +23,13 @@ def no_storage_ctx(): def test_storage_get_null(no_storage_ctx): - with no_storage_ctx.manager("update-status", State()) as mgr: + with no_storage_ctx.manager(no_storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages assert not len(storages) def test_storage_get_unknown_name(storage_ctx): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(KeyError): @@ -37,7 +37,7 @@ def test_storage_get_unknown_name(storage_ctx): def test_storage_request_unknown_name(storage_ctx): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(ModelError): @@ -45,7 +45,7 @@ def test_storage_request_unknown_name(storage_ctx): def test_storage_get_some(storage_ctx): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # known but none attached assert storages["foo"] == [] @@ -53,7 +53,7 @@ def test_storage_get_some(storage_ctx): @pytest.mark.parametrize("n", (1, 3, 5)) def test_storage_add(storage_ctx, n): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages storages.request("foo", n) @@ -65,7 +65,9 @@ def test_storage_usage(storage_ctx): # setup storage with some content (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") - with storage_ctx.manager("update-status", State(storage=[storage])) as mgr: + with storage_ctx.manager( + storage_ctx.on.update_status(), State(storage=[storage]) + ) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -83,9 +85,9 @@ def test_storage_usage(storage_ctx): def test_storage_attached_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage.attached_event, State(storage=[storage])) + storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storage=[storage])) def test_storage_detaching_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage.detaching_event, State(storage=[storage])) + storage_ctx.run(storage_ctx.on.storage_detaching(storage), State(storage=[storage])) diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index 6b6f902e..c8702611 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -55,8 +55,9 @@ def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): raw_ori_meta = yaml.safe_dump({"name": "karl"}) meta_file.write_text(raw_ori_meta) - with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager( - "start", + ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) + with ctx.manager( + ctx.on.start(), State(), ) as mgr: assert meta_file.exists() @@ -77,8 +78,9 @@ def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root): assert not meta_file.exists() - with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager( - "start", + ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) + with ctx.manager( + ctx.on.start(), State(), ) as mgr: assert meta_file.exists() diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index 7fc0eb00..b54c84b4 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -2,8 +2,9 @@ from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent -from scenario import Event, State +from scenario import State from scenario.capture_events import capture_events +from scenario.state import _Event from tests.helpers import trigger @@ -31,31 +32,16 @@ def _on_foo(self, e): pass -def test_capture_custom_evt(): - with capture_events(Foo) as emitted: - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - - assert len(emitted) == 1 - assert isinstance(emitted[0], Foo) - - -def test_capture_custom_evt_nonspecific_capture(): - with capture_events() as emitted: - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - - assert len(emitted) == 1 - assert isinstance(emitted[0], Foo) - - def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): with capture_events(include_framework=True) as emitted: - trigger(State(), "foo", MyCharm, meta=MyCharm.META) + trigger(State(), "start", MyCharm, meta=MyCharm.META) - assert len(emitted) == 4 - assert isinstance(emitted[0], Foo) - assert isinstance(emitted[1], CollectStatusEvent) - assert isinstance(emitted[2], PreCommitEvent) - assert isinstance(emitted[3], CommitEvent) + assert len(emitted) == 5 + assert isinstance(emitted[0], StartEvent) + assert isinstance(emitted[1], Foo) + assert isinstance(emitted[2], CollectStatusEvent) + assert isinstance(emitted[3], PreCommitEvent) + assert isinstance(emitted[4], CommitEvent) def test_capture_juju_evt(): @@ -71,7 +57,7 @@ def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events() as emitted: trigger( - State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]), + State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", MyCharm, meta=MyCharm.META, @@ -87,7 +73,7 @@ def test_capture_no_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events(include_deferred=False) as emitted: trigger( - State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]), + State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", MyCharm, meta=MyCharm.META, diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 06873f17..38bca584 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -21,7 +21,7 @@ def context(): return Context(charm_type=MyCharm, meta={"name": "foo"}) def test_sth(context): - context.run('start', State()) + context.run(context.on.start(), State()) """ ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 1fa0e884..eaaf99ef 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -10,7 +10,7 @@ from scenario import Context from scenario.runtime import Runtime, UncaughtCharmError -from scenario.state import Event, Relation, State, _CharmSpec +from scenario.state import Relation, State, _CharmSpec, _Event def charm_type(): @@ -56,7 +56,9 @@ class MyEvt(EventBase): ) with runtime.exec( - state=State(), event=Event("bar"), context=Context(my_charm_type, meta=meta) + state=State(), + event=_Event("bar"), + context=Context(my_charm_type, meta=meta), ) as ops: pass @@ -84,7 +86,7 @@ def test_unit_name(app_name, unit_id): with runtime.exec( state=State(), - event=Event("start"), + event=_Event("start"), context=Context(my_charm_type, meta=meta), ) as ops: assert ops.charm.unit.name == f"{app_name}/{unit_id}" @@ -105,7 +107,7 @@ def test_env_cleanup_on_charm_error(): with pytest.raises(UncaughtCharmError): with runtime.exec( state=State(), - event=Event("box_relation_changed", relation=Relation("box")), + event=_Event("box_relation_changed", relation=Relation("box")), context=Context(my_charm_type, meta=meta), ): assert os.getenv("JUJU_REMOTE_APP") diff --git a/tox.ini b/tox.ini index a404c5f6..e939714b 100644 --- a/tox.ini +++ b/tox.ini @@ -56,7 +56,7 @@ deps = coverage[toml] isort commands = - black --check {[vars]tst_path} {[vars]src_path} + black --check {[vars]tst_path} isort --check-only --profile black {[vars]tst_path} [testenv:fmt] From 1461f12d8efe930596f7a9adecb75e87012aedb5 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 17:40:48 +1200 Subject: [PATCH 12/35] Test the code in the README. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e69d061c..590604e2 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ class MyCharm(ops.CharmBase): self.model.unit.status = ops.ActiveStatus("foo") # ... +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) ctx.run(ctx.on.start(), scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! @@ -274,7 +275,7 @@ with scenario.capture_events.capture_events() as emitted: ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( ctx.on.update_status(), - scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) + scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) ) # deferred events get reemitted first From 7b4461563e219ed5abe29558444676a233cf453d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Apr 2024 14:31:41 +1200 Subject: [PATCH 13/35] Support 'ctx.on.event_name' for specifying events. Update tests and docs to match final (hopefully\!) API decision. Fix tests. The failing test are (a) ones that need to be rewritten for the new system, (b) ones to do with custom events. Various README updates. Typo Style fixes. --- README.md | 50 ++++++++++++++++++++------------------------ tests/test_plugin.py | 6 +++--- tox.ini | 2 +- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 590604e2..c47c5fd0 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ hook execution: ```python # ... ctx = scenario.Context(HistoryCharm, meta={"name": "foo"}) -ctx.run("start", scenario.State()) +ctx.run(ctx.on.start(), scenario.State()) assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` @@ -422,21 +422,16 @@ relation.remote_unit_name # "zookeeper/42" ### Triggering Relation Events -If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the -event from one of its aptly-named properties: +If you want to trigger relation events, use `ctx.on.relation_changed` (and so +on for the other relation events) and pass the relation object: ```python -relation = scenario.Relation(endpoint="foo", interface="bar") -changed_event = relation.changed_event -joined_event = relation.joined_event -# ... -``` - -This is in fact syntactic sugar for: +ctx = scenario.Context(MyCharm, meta=MyCharm.META) -```python relation = scenario.Relation(endpoint="foo", interface="bar") -changed_event = scenario.Event('foo-relation-changed', relation=relation) +changed_event = ctx.on.relation_changed(relation=relation) +joined_event = ctx.on.relation_joined(relation=relation) +# ... ``` The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario @@ -474,20 +469,16 @@ All relation events have some additional metadata that does not belong in the Re relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. -In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the +In order to supply this parameter, as well as the relation object, pass as `remote_unit` the id of the remote unit that the event is about. The reason that this parameter is not supplied to `Relation` but to relation events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr), but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one. -The `remote_unit_id` will default to the first ID found in the relation's `remote_units_data`, but if the test you are -writing is close to that domain, you should probably override it and pass it manually. - ```python -relation = scenario.Relation(endpoint="foo", interface="bar") -remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) -# which is syntactic sugar for: -remote_unit_2_is_joining_event = scenario.Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) +relation = scenario.Relation(endpoint="foo", interface="bar") +remote_unit_2_is_joining_event = ctx.on.relation_joined(relation, remote_unit=2) ``` ## Networks @@ -718,7 +709,7 @@ storage = scenario.Storage("foo") # Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager("update-status", scenario.State(storage=[storage])) as mgr: +with ctx.manager(ctx.on.update_status(), scenario.State(storage=[storage])) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -890,7 +881,7 @@ So, the only consistency-level check we enforce in Scenario when it comes to res import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) -with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: +with ctx.manager(ctx.on.start(), scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') assert path == pathlib.Path('/path/to/resource.tar') @@ -1031,8 +1022,10 @@ You can also generate the 'deferred' data structure (called a DeferredEvent) fro handler): ```python continuation -deferred_start = scenario.Event('start').deferred(MyCharm._on_start) -deferred_install = scenario.Event('install').deferred(MyCharm._on_start) +ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) + +deferred_start = ctx.on.start().deferred(MyCharm._on_start) +deferred_install = ctx.on.install().deferred(MyCharm._on_start) ``` On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed @@ -1081,8 +1074,10 @@ def test_start_on_deferred_update_status(MyCharm): but you can also use a shortcut from the relation event itself: ```python continuation +ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) + foo_relation = scenario.Relation('foo') -foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +deferred_event = ctx.on.relation_changed(foo_relation).deferred(handler=MyCharm._on_foo_relation_changed) ``` # Live charm introspection @@ -1167,11 +1162,12 @@ class MyCharmType(ops.CharmBase): td = tempfile.TemporaryDirectory() -state = scenario.Context( +ctx = scenario.Context( charm_type=MyCharmType, meta={'name': 'my-charm-name'}, charm_root=td.name -).run(ctx.on.start(), scenario.State()) +) +state = ctx.run(ctx.on.start(), scenario.State()) ``` Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 38bca584..b802b9eb 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -12,14 +12,14 @@ def test_plugin_ctx_run(pytester): from scenario import State from scenario import Context import ops - + class MyCharm(ops.CharmBase): pass - + @pytest.fixture def context(): return Context(charm_type=MyCharm, meta={"name": "foo"}) - + def test_sth(context): context.run(context.on.start(), State()) """ diff --git a/tox.ini b/tox.ini index e939714b..317a3b14 100644 --- a/tox.ini +++ b/tox.ini @@ -90,7 +90,7 @@ allowlist_externals = mkdir cp deps = - . + -e . ops pytest pytest-markdown-docs From 871c0f5c3a43b248686ab8a24e2fd92f342fd729 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 18:49:05 +1200 Subject: [PATCH 14/35] Align with upstream. From 8c62a1ea2d663df001c43e8e5c8b6c9252dabf16 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Jul 2024 23:03:42 +1200 Subject: [PATCH 15/35] Fix merging. Most particularly, the relation_id to id change was merged in main (mistakenly) and then unmerged, so the rebasing undid it, so that is redone. Also, the basic changes for Pebble notices and Cloud Configuration are done in here, although those need to be double-checked to make sure they make sense with the updated API. --- README.md | 4 ++-- scenario/consistency_checker.py | 10 +++++----- scenario/mocking.py | 6 ++---- scenario/ops_main_mock.py | 2 +- scenario/runtime.py | 2 +- scenario/state.py | 11 ++++++----- tests/test_consistency_checker.py | 22 ++++++++-------------- tests/test_e2e/test_cloud_spec.py | 6 +++--- tests/test_e2e/test_deferred.py | 2 +- tests/test_e2e/test_network.py | 4 ++-- tests/test_e2e/test_pebble.py | 2 +- tests/test_e2e/test_play_assertions.py | 2 +- tests/test_e2e/test_relations.py | 2 +- tests/test_e2e/test_secrets.py | 10 +++++----- 14 files changed, 39 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index c47c5fd0..c3e403bc 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ needs to set up the process that will run `ops.main` with the right environment ### Working with relation IDs -Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. +Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `id`. To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`. ```python @@ -447,7 +447,7 @@ import scenario.state next_id = scenario.state.next_relation_id(update=False) rel = scenario.Relation('foo') -assert rel.relation_id == next_id +assert rel.id == next_id ``` This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 004032fa..8c2837bb 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -125,7 +125,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. @@ -519,13 +519,13 @@ def _get_relations(r): expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: - if relation.relation_id in seen_ids: + if relation.id in seen_ids: errors.append( - f"duplicate relation ID: {relation.relation_id} is claimed " + f"duplicate relation ID: {relation.id} is claimed " f"by multiple Relation instances", ) - seen_ids.add(relation.relation_id) + seen_ids.add(relation.id) is_sub = isinstance(relation, SubordinateRelation) if is_sub and not expected_sub: errors.append( @@ -609,7 +609,7 @@ def check_containers_consistency( def check_cloudspec_consistency( *, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: diff --git a/scenario/mocking.py b/scenario/mocking.py index 5f2c17c6..183c45ba 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -163,7 +163,7 @@ def _get_relation_by_id( ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: return next( - filter(lambda r: r.relation_id == rel_id, self._state.relations), + filter(lambda r: r.id == rel_id, self._state.relations), ) except StopIteration: raise RelationNotFoundError() @@ -245,9 +245,7 @@ def status_get(self, *, is_app: bool = False): def relation_ids(self, relation_name): return [ - rel.relation_id - for rel in self._state.relations - if rel.endpoint == relation_name + rel.id for rel in self._state.relations if rel.endpoint == relation_name ] def relation_list(self, relation_id: int) -> Tuple[str, ...]: diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index d2a0371a..b9bcbb8f 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -140,7 +140,7 @@ def setup_framework( # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( - event.relation.relation_id # type: ignore + event.relation.id # type: ignore if event.name.endswith("_relation_broken") else None ) diff --git a/scenario/runtime.py b/scenario/runtime.py index 114e66a9..97a7c773 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -211,7 +211,7 @@ def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): env.update( { "JUJU_RELATION": relation.endpoint, - "JUJU_RELATION_ID": str(relation.relation_id), + "JUJU_RELATION_ID": str(relation.id), "JUJU_REMOTE_APP": remote_app_name, }, ) diff --git a/scenario/state.py b/scenario/state.py index de814e3d..3b873368 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -358,7 +358,7 @@ class _RelationBase: """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.""" - relation_id: int = dataclasses.field(default_factory=next_relation_id) + id: int = dataclasses.field(default_factory=next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, if there's trouble, override.""" @@ -614,7 +614,7 @@ def next_notice_id(update=True): @dataclasses.dataclass(frozen=True) -class Notice(_DCBase): +class Notice: key: str """The notice key, a string that differentiates notices of this type. @@ -673,7 +673,7 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class _BoundNotice(_DCBase): +class _BoundNotice: notice: Notice container: "Container" @@ -681,7 +681,7 @@ class _BoundNotice(_DCBase): def event(self): """Sugar to generate a -pebble-custom-notice event for this notice.""" suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX - return Event( + return _Event( path=normalize_name(self.container.name) + suffix, container=self.container, notice=self.notice, @@ -833,6 +833,7 @@ def get_notice( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) + _RawStatusLiteral = Literal[ "waiting", "blocked", @@ -1450,7 +1451,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = { "relation_name": relation.endpoint, - "relation_id": relation.relation_id, + "relation_id": relation.id, "app_name": remote_app, "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", } diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 217a68d9..6a955be7 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -69,18 +69,18 @@ def test_workload_event_without_container(): ) assert_inconsistent( State(), - Event("foo-pebble-custom-notice", container=Container("foo")), + _Event("foo-pebble-custom-notice", container=Container("foo")), _CharmSpec(MyCharm, {}), ) notice = Notice("example.com/foo") assert_consistent( State(containers=[Container("foo", notices=[notice])]), - Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), + _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) assert_inconsistent( State(containers=[Container("foo")]), - Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), + _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -459,9 +459,7 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( - State( - relations=[Relation("foo", id=1), Relation("bar", id=1)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), _Event("start"), _CharmSpec( MyCharm, @@ -474,17 +472,13 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( - State( - relations=[Relation("foo", id=1), Relation("bar", id=1)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), _Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( - State( - relations=[Relation("foo", id=1), Relation("bar", id=2)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=2)]), _Event("start"), _CharmSpec( MyCharm, @@ -658,7 +652,7 @@ def test_cloudspec_consistency(): assert_consistent( State(model=Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec)), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "MyVMCharm"}, @@ -667,7 +661,7 @@ def test_cloudspec_consistency(): assert_inconsistent( State(model=Model(name="k8s-model", type="kubernetes", cloud_spec=cloud_spec)), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "MyK8sCharm"}, diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 8ce413f8..1834b3da 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -47,14 +47,14 @@ def test_get_cloud_spec(): name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec ), ) - with ctx.manager("start", state=state) as mgr: + with ctx.manager(ctx.on.start(), state=state) as mgr: assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec def test_get_cloud_spec_error(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state = scenario.State(model=scenario.Model(name="lxd-model", type="lxd")) - with ctx.manager("start", state) as mgr: + with ctx.manager(ctx.on.start(), state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() @@ -65,6 +65,6 @@ def test_get_cloud_spec_untrusted(): state = scenario.State( model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec), ) - with ctx.manager("start", state) as mgr: + with ctx.manager(ctx.on.start(), state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 8645c77b..fccb326c 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -169,7 +169,7 @@ def test_deferred_relation_event_from_relation(mycharm): assert out.deferred[0].name == "foo_relation_changed" assert out.deferred[0].snapshot_data == { "relation_name": rel.endpoint, - "relation_id": rel.relation_id, + "relation_id": rel.id, "app_name": "remote", "unit_name": "remote/1", } diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 5c08b949..68324647 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -48,7 +48,7 @@ def test_ip_get(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - relation_id=1, + id=1, ), ], networks={"foo": Network.default(private_address="4.4.4.4")}, @@ -110,7 +110,7 @@ def test_no_relation_error(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - relation_id=1, + id=1, ), ], networks={"bar": Network.default()}, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index bf83c6c6..a9223120 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,5 +1,5 @@ -import datetime import dataclasses +import datetime import tempfile from pathlib import Path diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index a5166db7..7fe07899 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -104,7 +104,7 @@ def check_relation_data(charm): Relation( endpoint="relation_test", interface="azdrubales", - relation_id=1, + id=1, remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index f3447cbe..e72f754c 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -407,7 +407,7 @@ def test_relation_ids(): initial_id = _next_relation_id_counter for i in range(10): rel = Relation("foo") - assert rel.relation_id == initial_id + i + assert rel.id == initial_id + i def test_broken_relation_not_in_model_relations(mycharm): diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 164b250b..d4341495 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -201,7 +201,9 @@ def test_set_legacy_behaviour(mycharm): ) secret.set_content(rev2) - secret = charm.model.get_secret(label="mylabel") + # We need to get the secret again, because ops caches the content in + # the object. + secret: ops_Secret = charm.model.get_secret(label="mylabel") assert ( secret.get_content() == secret.peek_content() @@ -211,7 +213,7 @@ def test_set_legacy_behaviour(mycharm): secret.set_content(rev3) state_out = mgr.run() - secret = charm.model.get_secret(label="mylabel") + secret: ops_Secret = charm.model.get_secret(label="mylabel") assert ( secret.get_content() == secret.peek_content() @@ -513,9 +515,7 @@ def __init__(self, *args): state = State( leader=True, relations=[ - Relation( - "bar", remote_app_name=relation_remote_app, relation_id=relation_id - ) + Relation("bar", remote_app_name=relation_remote_app, id=relation_id) ], ) From c2bd565d4c5aa992fdd524af05a9520a282d0f18 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Thu, 4 Jul 2024 14:07:16 +0900 Subject: [PATCH 16/35] Mark upcoming branch as dev or beta version What do you think? --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4fbb741c..5ce0b947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.6" +version = "7.0.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 0c55e6473c604c7f041fbc92201d59429fec070d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 5 Jul 2024 13:04:52 +1200 Subject: [PATCH 17/35] Require keyword arguments for the state and its components. --- README.md | 32 ++-- scenario/__init__.py | 8 +- scenario/context.py | 16 +- scenario/mocking.py | 24 +-- scenario/state.py | 228 ++++++++++++++++++++++------ tests/test_consistency_checker.py | 26 ++-- tests/test_context.py | 22 ++- tests/test_context_on.py | 14 +- tests/test_e2e/test_actions.py | 16 +- tests/test_e2e/test_pebble.py | 17 +-- tests/test_e2e/test_ports.py | 17 ++- tests/test_e2e/test_relations.py | 52 +++++++ tests/test_e2e/test_secrets.py | 20 +++ tests/test_e2e/test_state.py | 87 ++++++++++- tests/test_e2e/test_stored_state.py | 17 ++- 15 files changed, 481 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index c3e403bc..cbbfdc9e 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,8 @@ meta = { } ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) ctx.run(ctx.on.start(), state_in) # invalid: this unit's id cannot be the ID of a peer. + + ``` ### SubordinateRelation @@ -531,7 +533,7 @@ local_file = pathlib.Path('/path/to/local/real/file.txt') container = scenario.Container( name="foo", can_connect=True, - mounts={'local': scenario.Mount('/local/share/config.yaml', local_file)} + mounts={'local': scenario.Mount(location='/local/share/config.yaml', source=local_file)} ) state = scenario.State(containers=[container]) ``` @@ -568,7 +570,7 @@ def test_pebble_push(): container = scenario.Container( name='foo', can_connect=True, - mounts={'local': scenario.Mount('/local/share/config.yaml', local_file.name)} + mounts={'local': Mount(location='/local/share/config.yaml', source=local_file.name)} ) state_in = scenario.State(containers=[container]) ctx = scenario.Context( @@ -667,32 +669,29 @@ Pebble can generate notices, which Juju will detect, and wake up the charm to let it know that something has happened in the container. The most common use-case is Pebble custom notices, which is a mechanism for the workload application to trigger a charm event. - +- When the charm is notified, there might be a queue of existing notices, or just the one that has triggered the event: ```python -import ops -import scenario - class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) - framework.observe(self.on["cont"].pebble_custom_notice, self._on_notice) + framework.observe(self.on["my-container"].pebble_custom_notice, self._on_notice) def _on_notice(self, event): event.notice.key # == "example.com/c" - for notice in self.unit.get_container("cont").get_notices(): + for notice in self.unit.get_container("my-container").get_notices(): ... ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) notices = [ - scenario.Notice(key="example.com/a", occurences=10), + scenario.Notice(key="example.com/a", occurrences=10), scenario.Notice(key="example.com/b", last_data={"bar": "baz"}), scenario.Notice(key="example.com/c"), ] -cont = scenario.Container(notices=notices) -ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[cont])) +container = scenario.Container("my-container", notices=notices) +ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[container])) ``` ## Storage @@ -766,15 +765,14 @@ ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storage=[foo_0, foo_1])) Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host VM/container. Using the `State.opened_ports` API, you can: - simulate a charm run with a port opened by some previous execution -```python ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.Port("tcp", 42)])) +ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.TCPPort(42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python ctx = scenario.Context(PortCharm, meta=MyCharm.META) state1 = ctx.run(ctx.on.start(), scenario.State()) -assert state1.opened_ports == [scenario.Port("tcp", 42)] +assert state1.opened_ports == [scenario.TCPPort(42)] state2 = ctx.run(ctx.on.stop(), state1) assert state2.opened_ports == [] @@ -788,8 +786,8 @@ Scenario has secrets. Here's how you use them. state = scenario.State( secrets=[ scenario.Secret( + {0: {'key': 'public'}}, id='foo', - contents={0: {'key': 'public'}} ) ] ) @@ -817,8 +815,8 @@ To specify a secret owned by this unit (or app): state = scenario.State( secrets=[ scenario.Secret( + {0: {'key': 'private'}}, id='foo', - contents={0: {'key': 'private'}}, owner='unit', # or 'app' remote_grants={0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 @@ -833,8 +831,8 @@ To specify a secret owned by some other application and give this unit (or app) state = scenario.State( secrets=[ scenario.Secret( + {0: {'key': 'public'}}, id='foo', - contents={0: {'key': 'public'}}, # owner=None, which is the default revision=0, # the revision that this unit (or app) is currently tracking ) diff --git a/scenario/__init__.py b/scenario/__init__.py index 93059ebf..a73570a6 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -11,12 +11,12 @@ Container, DeferredEvent, ExecOutput, + ICMPPort, Model, Mount, Network, Notice, PeerRelation, - Port, Relation, Secret, State, @@ -24,6 +24,8 @@ Storage, StoredState, SubordinateRelation, + TCPPort, + UDPPort, deferred, ) @@ -47,7 +49,9 @@ "Address", "BindAddress", "Network", - "Port", + "ICMPPort", + "TCPPort", + "UDPPort", "Storage", "StoredState", "State", diff --git a/scenario/context.py b/scenario/context.py index db990e54..c563814b 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -19,6 +19,7 @@ Storage, _CharmSpec, _Event, + _max_posargs, ) if TYPE_CHECKING: # pragma: no cover @@ -34,8 +35,8 @@ DEFAULT_JUJU_VERSION = "3.4" -@dataclasses.dataclass -class ActionOutput: +@dataclasses.dataclass(frozen=True) +class ActionOutput(_max_posargs(0)): """Wraps the results of running an action event with ``run_action``.""" state: "State" @@ -388,6 +389,7 @@ def __init__( self, charm_type: Type["CharmType"], meta: Optional[Dict[str, Any]] = None, + *, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, charm_root: Optional["PathLike"] = None, @@ -454,7 +456,7 @@ def __init__( defined in metadata.yaml. :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with - ``juju trust``). Defaults to False + ``juju trust``). Defaults to False. :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. E.g.: @@ -627,10 +629,10 @@ def run_action(self, action: "Action", state: "State") -> ActionOutput: def _finalize_action(self, state_out: "State"): ao = ActionOutput( - state_out, - self._action_logs, - self._action_results, - self._action_failure, + state=state_out, + logs=self._action_logs, + results=self._action_results, + failure=self._action_failure, ) # reset all action-related state diff --git a/scenario/mocking.py b/scenario/mocking.py index 183c45ba..71570de1 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -20,8 +20,11 @@ cast, ) -from ops import CloudSpec, JujuVersion, pebble -from ops.model import ModelError, RelationNotFoundError +from ops import JujuVersion, pebble +from ops.model import CloudSpec as CloudSpec_Ops +from ops.model import ModelError +from ops.model import Port as Port_Ops +from ops.model import RelationNotFoundError from ops.model import Secret as Secret_Ops # lol from ops.model import ( SecretInfo, @@ -39,8 +42,8 @@ Mount, Network, PeerRelation, - Port, Storage, + _port_cls_by_protocol, _RawPortProtocolLiteral, _RawStatusLiteral, ) @@ -112,8 +115,11 @@ def __init__( self._context = context self._charm_spec = charm_spec - def opened_ports(self) -> Set[Port]: - return set(self._state.opened_ports) + def opened_ports(self) -> Set[Port_Ops]: + return { + Port_Ops(protocol=port.protocol, port=port.port) + for port in self._state.opened_ports + } def open_port( self, @@ -122,7 +128,7 @@ def open_port( ): # fixme: the charm will get hit with a StateValidationError # here, not the expected ModelError... - port_ = Port(protocol, port) + port_ = _port_cls_by_protocol[protocol](port=port) ports = self._state.opened_ports if port_ not in ports: ports.append(port_) @@ -132,7 +138,7 @@ def close_port( protocol: "_RawPortProtocolLiteral", port: Optional[int] = None, ): - _port = Port(protocol, port) + _port = _port_cls_by_protocol[protocol](port=port) ports = self._state.opened_ports if _port in ports: ports.remove(_port) @@ -632,7 +638,7 @@ def resource_get(self, resource_name: str) -> str: f"resource {resource_name} not found in State. please pass it.", ) - def credential_get(self) -> CloudSpec: + def credential_get(self) -> CloudSpec_Ops: if not self._context.app_trusted: raise ModelError( "ERROR charm is not trusted, initialise Context with `app_trusted=True`", @@ -672,7 +678,7 @@ def __init__( path = Path(mount.location).parts mounting_dir = container_root.joinpath(*path[1:]) mounting_dir.parent.mkdir(parents=True, exist_ok=True) - mounting_dir.symlink_to(mount.src) + mounting_dir.symlink_to(mount.source) self._root = container_root diff --git a/scenario/state.py b/scenario/state.py index 3b873368..c788e855 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -17,6 +17,7 @@ Any, Callable, Dict, + Final, Generic, List, Literal, @@ -30,10 +31,11 @@ ) from uuid import uuid4 -import ops import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents +from ops.model import CloudCredential as CloudCredential_Ops +from ops.model import CloudSpec as CloudSpec_Ops from ops.model import SecretRotate, StatusBase from scenario.logger import logger as scenario_logger @@ -123,8 +125,77 @@ class MetadataNotFoundError(RuntimeError): """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" +# This can be replaced with the KW_ONLY dataclasses functionality in Python 3.10+. +def _max_posargs(n: int): + class _MaxPositionalArgs: + """Raises TypeError when instantiating objects if arguments are not passed as keywords. + + Looks for a `_max_positional_args` class attribute, which should be an int + indicating the maximum number of positional arguments that can be passed to + `__init__` (excluding `self`). + """ + + _max_positional_args = n + + def __new__(cls, *args, **kwargs): + # inspect.signature guarantees the order of parameters is as + # declared, which aligns with dataclasses. Simpler ways of + # getting the arguments (like __annotations__) do not have that + # guarantee, although in practice it is the case. + parameters = inspect.signature(cls).parameters + required_args = [ + name + for name in tuple(parameters) + if parameters[name].default is inspect.Parameter.empty + and name not in kwargs + ] + n_posargs = len(args) + max_n_posargs = cls._max_positional_args + kw_only = { + name + for name in tuple(parameters)[max_n_posargs:] + if not name.startswith("_") + } + if n_posargs > max_n_posargs: + raise TypeError( + f"{cls.__name__} takes {max_n_posargs} positional " + f"argument{'' if max_n_posargs == 1 else 's'} but " + f"{n_posargs} {'was' if n_posargs == 1 else 'were'} " + f"given. The following arguments are keyword-only: " + f"{', '.join(kw_only)}", + ) from None + # Also check if there are just not enough arguments at all, because + # the default TypeError message will incorrectly describe some of + # the arguments as positional. + elif n_posargs < len(required_args): + required_pos = [ + f"'{arg}'" + for arg in required_args[n_posargs:] + if arg not in kw_only + ] + required_kw = { + f"'{arg}'" for arg in required_args[n_posargs:] if arg in kw_only + } + if required_pos and required_kw: + details = f"positional: {', '.join(required_pos)} and keyword: {', '.join(required_kw)} arguments" + elif required_pos: + details = f"positional argument{'' if len(required_pos) == 1 else 's'}: {', '.join(required_pos)}" + else: + details = f"keyword argument{'' if len(required_kw) == 1 else 's'}: {', '.join(required_kw)}" + raise TypeError(f"{cls.__name__} missing required {details}") from None + return super().__new__(cls) + + def __reduce__(self): + # The default __reduce__ doesn't understand that some arguments have + # to be passed as keywords, so using the copy module fails. + attrs = cast(Dict[str, Any], super().__reduce__()[2]) + return (lambda: self.__class__(**attrs), ()) + + return _MaxPositionalArgs + + @dataclasses.dataclass(frozen=True) -class CloudCredential: +class CloudCredential(_max_posargs(0)): auth_type: str """Authentication type.""" @@ -138,8 +209,8 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted generic cloud API secrets.""" - def _to_ops(self) -> ops.CloudCredential: - return ops.CloudCredential( + def _to_ops(self) -> CloudCredential_Ops: + return CloudCredential_Ops( auth_type=self.auth_type, attributes=self.attributes, redacted=self.redacted, @@ -147,7 +218,7 @@ def _to_ops(self) -> ops.CloudCredential: @dataclasses.dataclass(frozen=True) -class CloudSpec: +class CloudSpec(_max_posargs(1)): type: str """Type of the cloud.""" @@ -178,8 +249,8 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" - def _to_ops(self) -> ops.CloudSpec: - return ops.CloudSpec( + def _to_ops(self) -> CloudSpec_Ops: + return CloudSpec_Ops( type=self.type, name=self.name, region=self.region, @@ -194,16 +265,16 @@ def _to_ops(self) -> ops.CloudSpec: @dataclasses.dataclass(frozen=True) -class Secret: +class Secret(_max_posargs(1)): + # mapping from revision IDs to each revision's contents + contents: Dict[int, "RawSecretRevisionContents"] + id: str # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized # secret id (`secret:` prefix) # but user-created ones will not. Using post-init to patch it in feels bad, but requiring the user to # add the prefix manually every time seems painful as well. - # mapping from revision IDs to each revision's contents - contents: Dict[int, "RawSecretRevisionContents"] - # 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 @@ -257,7 +328,7 @@ def normalize_name(s: str): @dataclasses.dataclass(frozen=True) -class Address: +class Address(_max_posargs(1)): """An address in a Juju network space.""" hostname: str @@ -278,11 +349,12 @@ def address(self, value): @dataclasses.dataclass(frozen=True) -class BindAddress: +class BindAddress(_max_posargs(1)): """An address bound to a network interface in a Juju space.""" interface_name: str addresses: List[Address] + interface_name: str = "" mac_address: Optional[str] = None def hook_tool_output_fmt(self): @@ -298,7 +370,7 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network: +class Network(_max_posargs(0)): bind_addresses: List[BindAddress] ingress_addresses: List[str] egress_subnets: List[str] @@ -341,7 +413,7 @@ def default( _next_relation_id_counter = 1 -def next_relation_id(update=True): +def next_relation_id(*, update=True): global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -350,7 +422,7 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) -class _RelationBase: +class _RelationBase(_max_posargs(2)): endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -533,7 +605,7 @@ def _random_model_name(): @dataclasses.dataclass(frozen=True) -class Model: +class Model(_max_posargs(1)): """The Juju model in which the charm is deployed.""" name: str = dataclasses.field(default_factory=_random_model_name) @@ -568,7 +640,7 @@ def _generate_new_change_id(): @dataclasses.dataclass(frozen=True) -class ExecOutput: +class ExecOutput(_max_posargs(0)): """Mock data for simulated :meth:`ops.Container.exec` calls.""" return_code: int = 0 @@ -589,7 +661,7 @@ def _run(self) -> int: @dataclasses.dataclass(frozen=True) -class Mount: +class Mount(_max_posargs(0)): """Maps local files to a :class:`Container` filesystem.""" location: Union[str, PurePosixPath] @@ -605,7 +677,7 @@ def _now_utc(): _next_notice_id_counter = 1 -def next_notice_id(update=True): +def next_notice_id(*, update=True): global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -614,7 +686,7 @@ def next_notice_id(update=True): @dataclasses.dataclass(frozen=True) -class Notice: +class Notice(_max_posargs(1)): key: str """The notice key, a string that differentiates notices of this type. @@ -673,7 +745,7 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class _BoundNotice: +class _BoundNotice(_max_posargs(0)): notice: Notice container: "Container" @@ -689,7 +761,7 @@ def event(self): @dataclasses.dataclass(frozen=True) -class Container: +class Container(_max_posargs(1)): """A Kubernetes container where a charm's workload runs.""" name: str @@ -714,7 +786,18 @@ class Container: ) """The current status of each Pebble service running in the container.""" - # when the charm runs `pebble.pull`, it will return .open() from one of these paths. + # this is how you specify the contents of the filesystem: suppose you want to express that your + # container has: + # - /home/foo/bar.py + # - /bin/bash + # - /bin/baz + # + # this becomes: + # mounts = { + # 'foo': Mount(location='/home/foo/', source=Path('/path/to/local/dir/containing/bar/py/')) + # 'bin': Mount(location='/bin/', source=Path('/path/to/local/dir/containing/bash/and/baz/')) + # } + # when the charm runs `pebble.pull`, it will return .open() from one of those paths. # when the charm pushes, it will either overwrite one of those paths (careful!) or it will # create a tempfile and insert its path in the mock filesystem tree mounts: Dict[str, Mount] = dataclasses.field(default_factory=dict) @@ -828,7 +911,7 @@ def get_notice( """ for notice in self.notices: if notice.key == key and notice.type == notice_type: - return _BoundNotice(notice, self) + return _BoundNotice(notice=notice, container=self) raise KeyError( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) @@ -882,12 +965,13 @@ class _MyClass(_EntityStatus, statusbase_subclass): @dataclasses.dataclass(frozen=True) -class StoredState: +class StoredState(_max_posargs(1)): + name: str = "_stored" + # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. - owner_path: Optional[str] + owner_path: Optional[str] = None - name: str = "_stored" # 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 @@ -905,36 +989,80 @@ def handle_path(self): @dataclasses.dataclass(frozen=True) -class Port: +class _Port(_max_posargs(1)): """Represents a port on the charm host.""" - protocol: _RawPortProtocolLiteral - """The protocol that data transferred over the port will use.""" port: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" + protocol: _RawPortProtocolLiteral = "tcp" + """The protocol that data transferred over the port will use.""" + def __post_init__(self): - port = self.port - is_icmp = self.protocol == "icmp" - if port: - if is_icmp: - raise StateValidationError( - "`port` arg not supported with `icmp` protocol", - ) - if not (1 <= port <= 65535): - raise StateValidationError( - f"`port` outside bounds [1:65535], got {port}", - ) - elif not is_icmp: + if type(self) is _Port: + raise RuntimeError( + "_Port cannot be instantiated directly; " + "please use TCPPort, UDPPort, or ICMPPort", + ) + + +@dataclasses.dataclass(frozen=True) +class TCPPort(_Port): + """Represents a TCP port on the charm host.""" + + port: int + """The port to open.""" + protocol: _RawPortProtocolLiteral = "tcp" + + def __post_init__(self): + super().__post_init__() + if not (1 <= self.port <= 65535): raise StateValidationError( - f"`port` arg required with `{self.protocol}` protocol", + f"`port` outside bounds [1:65535], got {self.port}", ) +@dataclasses.dataclass(frozen=True) +class UDPPort(_Port): + """Represents a UDP port on the charm host.""" + + port: int + """The port to open.""" + protocol: _RawPortProtocolLiteral = "udp" + + def __post_init__(self): + super().__post_init__() + if not (1 <= self.port <= 65535): + raise StateValidationError( + f"`port` outside bounds [1:65535], got {self.port}", + ) + + +@dataclasses.dataclass(frozen=True) +class ICMPPort(_Port): + """Represents an ICMP port on the charm host.""" + + protocol: _RawPortProtocolLiteral = "icmp" + + _max_positional_args: Final = 0 + + def __post_init__(self): + super().__post_init__() + if self.port is not None: + raise StateValidationError("`port` cannot be set for `ICMPPort`") + + +_port_cls_by_protocol = { + "tcp": TCPPort, + "udp": UDPPort, + "icmp": ICMPPort, +} + + _next_storage_index_counter = 0 # storage indices start at 0 -def next_storage_index(update=True): +def next_storage_index(*, update=True): """Get the index (used to be called ID) the next Storage to be created will get. Pass update=False if you're only inspecting it. @@ -948,7 +1076,7 @@ def next_storage_index(update=True): @dataclasses.dataclass(frozen=True) -class Storage: +class Storage(_max_posargs(1)): """Represents an (attached!) storage made available to the charm container.""" name: str @@ -962,7 +1090,7 @@ def get_filesystem(self, ctx: "Context") -> Path: @dataclasses.dataclass(frozen=True) -class State: +class State(_max_posargs(0)): """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 @@ -991,7 +1119,7 @@ class State: If a storage is not attached, omit it from this listing.""" # we don't use sets to make json serialization easier - opened_ports: List[Port] = dataclasses.field(default_factory=list) + opened_ports: List[_Port] = dataclasses.field(default_factory=list) """Ports opened by juju on this charm.""" leader: bool = False """Whether this charm has leadership.""" @@ -1467,7 +1595,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: _next_action_id_counter = 1 -def next_action_id(update=True): +def next_action_id(*, update=True): global _next_action_id_counter cur = _next_action_id_counter if update: @@ -1478,7 +1606,7 @@ def next_action_id(update=True): @dataclasses.dataclass(frozen=True) -class Action: +class Action(_max_posargs(1)): """A ``juju run`` command. Used to simulate ``juju run``, passing in any parameters. For example:: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 6a955be7..82321558 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -3,7 +3,6 @@ import pytest from ops.charm import CharmBase -from scenario import Model from scenario.consistency_checker import check_consistency from scenario.runtime import InconsistentScenarioError from scenario.state import ( @@ -12,6 +11,7 @@ CloudCredential, CloudSpec, Container, + Model, Network, Notice, PeerRelation, @@ -285,7 +285,7 @@ def test_secrets_jujuv_bad(bad_v): @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) def test_secrets_jujuv_bad(good_v): assert_consistent( - State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), + State(secrets=[Secret(id="secret:foo", contents={0: {"a": "b"}})]), _Event("bar"), _CharmSpec(MyCharm, {}), good_v, @@ -293,7 +293,7 @@ def test_secrets_jujuv_bad(good_v): def test_secret_not_in_state(): - secret = Secret("secret:foo", {"a": "b"}) + secret = Secret(id="secret:foo", contents={"a": "b"}) assert_inconsistent( State(), _Event("secret_changed", secret=secret), @@ -673,10 +673,10 @@ def test_storedstate_consistency(): assert_consistent( State( stored_state=[ - StoredState(None, content={"foo": "bar"}), - StoredState(None, "my_stored_state", content={"foo": 1}), - StoredState("MyCharmLib", content={"foo": None}), - StoredState("OtherCharmLib", content={"foo": (1, 2, 3)}), + StoredState(content={"foo": "bar"}), + StoredState(name="my_stored_state", content={"foo": 1}), + StoredState(owner_path="MyCharmLib", content={"foo": None}), + StoredState(owner_path="OtherCharmLib", content={"foo": (1, 2, 3)}), ] ), _Event("start"), @@ -690,8 +690,8 @@ def test_storedstate_consistency(): assert_inconsistent( State( stored_state=[ - StoredState(None, content={"foo": "bar"}), - StoredState(None, "_stored", content={"foo": "bar"}), + StoredState(owner_path=None, content={"foo": "bar"}), + StoredState(owner_path=None, name="_stored", content={"foo": "bar"}), ] ), _Event("start"), @@ -703,7 +703,13 @@ def test_storedstate_consistency(): ), ) assert_inconsistent( - State(stored_state=[StoredState(None, content={"secret": Secret("foo", {})})]), + State( + stored_state=[ + StoredState( + owner_path=None, content={"secret": Secret(id="foo", contents={})} + ) + ] + ), _Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_context.py b/tests/test_context.py index d6995efc..aed14159 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,7 +3,7 @@ import pytest from ops import CharmBase -from scenario import Action, Context, State +from scenario import Action, ActionOutput, Context, State from scenario.state import _Event, next_action_id @@ -59,3 +59,23 @@ def test_app_name(app_name, unit_id): with ctx.manager(ctx.on.start(), State()) as mgr: assert mgr.charm.app.name == app_name assert mgr.charm.unit.name == f"{app_name}/{unit_id}" + + +def test_action_output_no_positional_arguments(): + with pytest.raises(TypeError): + ActionOutput(None, None) + + +def test_action_output_no_results(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.act_action, self._on_act_action) + + def _on_act_action(self, _): + pass + + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) + out = ctx.run_action(Action("act"), State()) + assert out.results is None + assert out.failure is None diff --git a/tests/test_context_on.py b/tests/test_context_on.py index be8c70b5..d9609d2e 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -81,7 +81,9 @@ def test_simple_events(event_name, event_kind): ) def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - secret = scenario.Secret("secret:123", {0: {"password": "xxxx"}}, owner=owner) + secret = scenario.Secret( + id="secret:123", contents={0: {"password": "xxxx"}}, owner=owner + ) state_in = scenario.State(secrets=[secret]) # These look like: # ctx.run(ctx.on.secret_changed(secret=secret), state) @@ -112,8 +114,8 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): def test_revision_secret_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - "secret:123", - {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + id="secret:123", + contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, owner="app", ) state_in = scenario.State(secrets=[secret]) @@ -135,7 +137,9 @@ def test_revision_secret_events(event_name, event_kind): def test_revision_secret_events_as_positional_arg(event_name): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - "secret:123", {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, owner=None + id="secret:123", + contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + owner=None, ) state_in = scenario.State(secrets=[secret]) with pytest.raises(TypeError): @@ -180,7 +184,7 @@ def test_action_event_no_params(): def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - action = scenario.Action("act", {"param": "hello"}) + action = scenario.Action("act", params={"param": "hello"}) # These look like: # ctx.run_action(ctx.on.action(action=action), state) # So that any parameters can be included and the ID can be customised. diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 6256885c..39a057e6 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,7 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, State, _Event +from scenario.state import Action, State, _Event, next_action_id @pytest.fixture(scope="function") @@ -154,3 +154,17 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): action = Action("foo", id=uuid) ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) ctx.run_action(action, State()) + + +def test_positional_arguments(): + with pytest.raises(TypeError): + Action("foo", {}) + + +def test_default_arguments(): + expected_id = next_action_id(update=False) + name = "foo" + action = Action(name) + assert action.name == name + assert action.params == {} + assert action.id == expected_id diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index a9223120..7dfbba67 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -10,7 +10,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import Container, ExecOutput, Mount, Notice, Port, State +from scenario.state import Container, ExecOutput, Mount, Notice, State from tests.helpers import jsonpatch_delta, trigger @@ -86,7 +86,7 @@ def callback(self: CharmBase): Container( name="foo", can_connect=True, - mounts={"bar": Mount("/bar/baz.txt", pth)}, + mounts={"bar": Mount(location="/bar/baz.txt", source=pth)}, ) ] ), @@ -97,10 +97,6 @@ def callback(self: CharmBase): ) -def test_port_equality(): - assert Port("tcp", 42) == Port("tcp", 42) - - @pytest.mark.parametrize("make_dirs", (True, False)) def test_fs_pull(charm_cls, make_dirs): text = "lorem ipsum/n alles amat gloriae foo" @@ -122,7 +118,9 @@ def callback(self: CharmBase): td = tempfile.TemporaryDirectory() container = Container( - name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)} + name="foo", + can_connect=True, + mounts={"foo": Mount(location="/foo", source=td.name)}, ) state = State(containers=[container]) @@ -135,14 +133,15 @@ def callback(self: CharmBase): callback(mgr.charm) if make_dirs: - # file = (out.get_container("foo").mounts["foo"].src + "bar/baz.txt").open("/foo/bar/baz.txt") + # file = (out.get_container("foo").mounts["foo"].source + "bar/baz.txt").open("/foo/bar/baz.txt") # this is one way to retrieve the file file = Path(td.name + "/bar/baz.txt") # another is: assert ( - file == Path(out.get_container("foo").mounts["foo"].src) / "bar" / "baz.txt" + file + == Path(out.get_container("foo").mounts["foo"].source) / "bar" / "baz.txt" ) # but that is actually a symlink to the context's root tmp folder: diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index 13502971..3a19148f 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -2,7 +2,7 @@ from ops import CharmBase, Framework, StartEvent, StopEvent from scenario import Context, State -from scenario.state import Port +from scenario.state import StateValidationError, TCPPort, UDPPort, _Port class MyCharm(CharmBase): @@ -35,5 +35,18 @@ def test_open_port(ctx): def test_close_port(ctx): - out = ctx.run(ctx.on.stop(), State(opened_ports=[Port("tcp", 42)])) + out = ctx.run(ctx.on.stop(), State(opened_ports=[TCPPort(42)])) assert not out.opened_ports + + +def test_port_no_arguments(): + with pytest.raises(RuntimeError): + _Port() + + +@pytest.mark.parametrize("klass", (TCPPort, UDPPort)) +def test_port_port(klass): + with pytest.raises(StateValidationError): + klass(port=0) + with pytest.raises(StateValidationError): + klass(port=65536) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index e72f754c..853c7ba5 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -21,6 +21,7 @@ StateValidationError, SubordinateRelation, _RelationBase, + next_relation_id, ) from tests.helpers import trigger @@ -421,3 +422,54 @@ def test_broken_relation_not_in_model_relations(mycharm): assert charm.model.get_relation("foo") is None assert charm.model.relations["foo"] == [] + + +@pytest.mark.parametrize("klass", (Relation, PeerRelation, SubordinateRelation)) +def test_relation_positional_arguments(klass): + with pytest.raises(TypeError): + klass("foo", "bar", None) + + +def test_relation_default_values(): + expected_id = next_relation_id(update=False) + endpoint = "database" + interface = "postgresql" + relation = Relation(endpoint, interface) + assert relation.id == expected_id + assert relation.endpoint == endpoint + assert relation.interface == interface + assert relation.local_app_data == {} + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_app_name == "remote" + assert relation.limit == 1 + assert relation.remote_app_data == {} + assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} + + +def test_subordinate_relation_default_values(): + expected_id = next_relation_id(update=False) + endpoint = "database" + interface = "postgresql" + relation = SubordinateRelation(endpoint, interface) + assert relation.id == expected_id + assert relation.endpoint == endpoint + assert relation.interface == interface + assert relation.local_app_data == {} + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_app_name == "remote" + assert relation.remote_unit_id == 0 + assert relation.remote_app_data == {} + assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG + + +def test_peer_relation_default_values(): + expected_id = next_relation_id(update=False) + endpoint = "peers" + interface = "shared" + relation = PeerRelation(endpoint, interface) + assert relation.id == expected_id + assert relation.endpoint == endpoint + assert relation.interface == interface + assert relation.local_app_data == {} + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.peers_data == {0: DEFAULT_JUJU_DATABAG} diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index d4341495..5958781c 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -572,3 +572,23 @@ def _on_event(self, event): mgr.run() juju_event = mgr.charm.events[0] # Ignore collect-status etc. assert isinstance(juju_event, cls) + + +def test_no_additional_positional_arguments(): + with pytest.raises(TypeError): + Secret({}, None) + + +def test_default_values(): + contents = {"foo": "bar"} + id = "secret:1" + secret = Secret(contents, id=id) + assert secret.contents == contents + assert secret.id == id + assert secret.label is None + assert secret.revision == 0 + assert secret.description is None + assert secret.owner is None + assert secret.rotate is None + assert secret.expire is None + assert secret.remote_grants == {} diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 0c79da86..3f119909 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,3 +1,4 @@ +import copy from dataclasses import asdict, replace from typing import Type @@ -6,7 +7,16 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State +from scenario.state import ( + DEFAULT_JUJU_DATABAG, + Address, + BindAddress, + Container, + Model, + Network, + Relation, + State, +) from tests.helpers import jsonpatch_delta, sort_patch, trigger CUSTOM_EVT_SUFFIXES = { @@ -231,3 +241,78 @@ def pre_event(charm: CharmBase): assert out.relations[0].local_app_data == {"a": "b"} assert out.relations[0].local_unit_data == {"c": "d", **DEFAULT_JUJU_DATABAG} + + +@pytest.mark.parametrize( + "klass,num_args", + [ + (State, (1,)), + (Address, (0, 2)), + (BindAddress, (0, 2)), + (Network, (0, 2)), + ], +) +def test_positional_arguments(klass, num_args): + for num in num_args: + args = (None,) * num + with pytest.raises(TypeError): + klass(*args) + + +def test_model_positional_arguments(): + with pytest.raises(TypeError): + Model("", "") + + +def test_container_positional_arguments(): + with pytest.raises(TypeError): + Container("", "") + + +def test_container_default_values(): + name = "foo" + container = Container(name) + assert container.name == name + assert container.can_connect is False + assert container.layers == {} + assert container.service_status == {} + assert container.mounts == {} + assert container.exec_mock == {} + assert container.layers == {} + assert container._base_plan == {} + + +def test_state_default_values(): + state = State() + assert state.config == {} + assert state.relations == [] + assert state.networks == {} + assert state.containers == [] + assert state.storage == [] + assert state.opened_ports == [] + assert state.secrets == [] + assert state.resources == {} + assert state.deferred == [] + assert isinstance(state.model, Model) + assert state.leader is False + assert state.planned_units == 1 + assert state.app_status == UnknownStatus() + assert state.unit_status == UnknownStatus() + assert state.workload_version == "" + + +def test_deepcopy_state(): + containers = [Container("foo"), Container("bar")] + state = State(containers=containers) + state_copy = copy.deepcopy(state) + for container in state.containers: + copied_container = state_copy.get_container(container.name) + assert container.name == copied_container.name + + +def test_replace_state(): + containers = [Container("foo"), Container("bar")] + state = State(containers=containers, leader=True) + state2 = replace(state, leader=False) + assert state.leader != state2.leader + assert state.containers == state2.containers diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 22a6235e..38c38efd 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -39,7 +39,9 @@ def test_stored_state_initialized(mycharm): out = trigger( State( stored_state=[ - StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}), + StoredState( + owner_path="MyCharm", name="_stored", content={"foo": "FOOX"} + ), ] ), "start", @@ -49,3 +51,16 @@ def test_stored_state_initialized(mycharm): # todo: ordering is messy? assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} + + +def test_positional_arguments(): + with pytest.raises(TypeError): + StoredState("_stored", "") + + +def test_default_arguments(): + s = StoredState() + assert s.name == "_stored" + assert s.owner_path == None + assert s.content == {} + assert s._data_type_name == "StoredStateData" From 5285060bea83c198514b01ea5606374cc12cd96c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 9 Jul 2024 19:04:01 +1200 Subject: [PATCH 18/35] feat!: use sets for the state components (#134) * Remove _DCBase. * Adjust the consistency checker and expose the Resource class. * Finish the conversion (all tests pass). * Don't add __eq__ for now. * Update scenario/mocking.py * Allow getting components by passing in the old entity. * Revert back to the simpler get_ methods. * Fix merges. * Remove unused method (was used in the old binding, not generally useful). * Add a basic test for resources. * Add a basic test for resources. * Make networks a set as well. --- README.md | 87 ++++++------- scenario/__init__.py | 2 + scenario/consistency_checker.py | 29 ++--- scenario/mocking.py | 54 ++++---- scenario/runtime.py | 18 +-- scenario/state.py | 166 +++++++++++++++++++++---- tests/helpers.py | 25 ++-- tests/test_charm_spec_autoload.py | 2 +- tests/test_consistency_checker.py | 102 ++++++--------- tests/test_context_on.py | 2 +- tests/test_e2e/test_deferred.py | 8 +- tests/test_e2e/test_network.py | 4 +- tests/test_e2e/test_pebble.py | 32 ++--- tests/test_e2e/test_play_assertions.py | 6 +- tests/test_e2e/test_ports.py | 5 +- tests/test_e2e/test_relations.py | 28 ++--- tests/test_e2e/test_resource.py | 34 +++++ tests/test_e2e/test_secrets.py | 58 ++++----- tests/test_e2e/test_state.py | 40 +++--- tests/test_e2e/test_storage.py | 8 +- tests/test_e2e/test_stored_state.py | 24 +++- 21 files changed, 451 insertions(+), 283 deletions(-) create mode 100644 tests/test_e2e/test_resource.py diff --git a/README.md b/README.md index cbbfdc9e..d730e788 100644 --- a/README.md +++ b/README.md @@ -322,22 +322,21 @@ class MyCharm(ops.CharmBase): def test_relation_data(): - state_in = scenario.State(relations=[ - scenario.Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ]) + rel = scenario.Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ) + state_in = scenario.State(relations={rel}) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_out = ctx.run(ctx.on.start(), state_in) - assert state_out.relations[0].local_unit_data == {"abc": "baz!"} - # you can do this to check that there are no other differences: - assert state_out.relations == [ + assert state_out.get_relation(rel.id).local_unit_data == {"abc": "baz!"} + # You can do this to check that there are no other differences: + assert state_out.relations == { scenario.Relation( endpoint="foo", interface="bar", @@ -345,7 +344,7 @@ def test_relation_data(): local_unit_data={"abc": "baz!"}, remote_app_data={"cde": "baz!"}, ), - ] + } # which is very idiomatic and superbly explicit. Noice. ``` @@ -381,11 +380,11 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee be flagged by the Consistency Checker: ```python -state_in = scenario.State(relations=[ +state_in = scenario.State(relations={ scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, - )]) + )}) meta = { "name": "invalid", @@ -496,7 +495,7 @@ If you want to, you can override any of these relation or extra-binding associat ```python state = scenario.State(networks={ - 'foo': scenario.Network.default(private_address='192.0.2.1') + scenario.Network.default("foo", private_address='192.0.2.1') }) ``` @@ -508,15 +507,15 @@ When testing a Kubernetes charm, you can mock container interactions. When using be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. To give the charm access to some containers, you need to pass them to the input state, like so: -`State(containers=[...])` +`State(containers={...})` An example of a state including some containers: ```python -state = scenario.State(containers=[ +state = scenario.State(containers={ scenario.Container(name="foo", can_connect=True), scenario.Container(name="bar", can_connect=False) -]) +}) ``` In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. @@ -535,7 +534,7 @@ container = scenario.Container( can_connect=True, mounts={'local': scenario.Mount(location='/local/share/config.yaml', source=local_file)} ) -state = scenario.State(containers=[container]) +state = scenario.State(containers={container}) ``` In this case, if the charm were to: @@ -572,8 +571,8 @@ def test_pebble_push(): can_connect=True, mounts={'local': Mount(location='/local/share/config.yaml', source=local_file.name)} ) - state_in = scenario.State(containers=[container]) - ctx = scenario.Context( + state_in = State(containers={container}) + ctx = Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} ) @@ -606,7 +605,7 @@ class MyCharm(ops.CharmBase): def test_pebble_push(): container = scenario.Container(name='foo', can_connect=True) - state_in = scenario.State(containers=[container]) + state_in = scenario.State(containers={container}) ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} @@ -652,7 +651,7 @@ def test_pebble_exec(): stdout=LS_LL) } ) - state_in = scenario.State(containers=[container]) + state_in = scenario.State(containers={container}) ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}}, @@ -708,7 +707,7 @@ storage = scenario.Storage("foo") # Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager(ctx.on.update_status(), scenario.State(storage=[storage])) as mgr: +with ctx.manager(ctx.on.update_status(), scenario.State(storages={storage})) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -753,11 +752,11 @@ So a natural follow-up Scenario test suite for this case would be: ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storage=[foo_0])) +ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storages={foo_0})) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storage=[foo_0, foo_1])) +ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storages={foo_0, foo_1})) ``` ## Ports @@ -766,7 +765,7 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.TCPPort(42)])) +ctx.run(ctx.on.start(), scenario.State(opened_ports={scenario.TCPPort(42)})) ``` - assert that a charm has called `open-port` or `close-port`: ```python @@ -775,7 +774,7 @@ state1 = ctx.run(ctx.on.start(), scenario.State()) assert state1.opened_ports == [scenario.TCPPort(42)] state2 = ctx.run(ctx.on.stop(), state1) -assert state2.opened_ports == [] +assert state2.opened_ports == {} ``` ## Secrets @@ -784,12 +783,12 @@ Scenario has secrets. Here's how you use them. ```python state = scenario.State( - secrets=[ + secrets={ scenario.Secret( {0: {'key': 'public'}}, id='foo', - ) - ] + ), + }, ) ``` @@ -813,15 +812,15 @@ To specify a secret owned by this unit (or app): ```python state = scenario.State( - secrets=[ + secrets={ scenario.Secret( {0: {'key': 'private'}}, id='foo', owner='unit', # or 'app' remote_grants={0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 - ) - ] + ), + }, ) ``` @@ -829,14 +828,14 @@ To specify a secret owned by some other application and give this unit (or app) ```python state = scenario.State( - secrets=[ + secrets={ scenario.Secret( {0: {'key': 'public'}}, id='foo', # owner=None, which is the default revision=0, # the revision that this unit (or app) is currently tracking - ) - ] + ), + }, ) ``` @@ -853,15 +852,16 @@ class MyCharmType(ops.CharmBase): assert self.my_stored_state.foo == 'bar' # this will pass! -state = scenario.State(stored_state=[ +state = scenario.State(stored_states={ scenario.StoredState( owner_path="MyCharmType", name="my_stored_state", content={ 'foo': 'bar', 'baz': {42: 42}, - }) -]) + }), + }, +) ``` And the charm's runtime will see `self.my_stored_state.foo` and `.baz` as expected. Also, you can run assertions on it on @@ -879,7 +879,8 @@ So, the only consistency-level check we enforce in Scenario when it comes to res import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) -with ctx.manager(ctx.on.start(), scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: +resource = scenario.Resource(name='foo', path='/path/to/resource.tar') +with ctx.manager(ctx.on.start(), scenario.State(resources={resource})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') assert path == pathlib.Path('/path/to/resource.tar') @@ -1060,7 +1061,7 @@ class MyCharm(ops.CharmBase): def test_start_on_deferred_update_status(MyCharm): foo_relation = scenario.Relation('foo') scenario.State( - relations=[foo_relation], + relations={foo_relation}, deferred=[ scenario.deferred('foo_relation_changed', handler=MyCharm._on_foo_relation_changed, diff --git a/scenario/__init__.py b/scenario/__init__.py index a73570a6..fafc3631 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -18,6 +18,7 @@ Notice, PeerRelation, Relation, + Resource, Secret, State, StateValidationError, @@ -52,6 +53,7 @@ "ICMPPort", "TCPPort", "UDPPort", + "Resource", "Storage", "StoredState", "State", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 8c2837bb..be9b7fc1 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -4,7 +4,7 @@ import marshal import os import re -from collections import Counter, defaultdict +from collections import defaultdict from collections.abc import Sequence from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union @@ -108,7 +108,7 @@ def check_resource_consistency( warnings = [] resources_from_meta = set(charm_spec.meta.get("resources", {})) - resources_from_state = set(state.resources) + resources_from_state = {resource.name for resource in state.resources} if not resources_from_meta.issuperset(resources_from_state): errors.append( f"any and all resources passed to State.resources need to have been defined in " @@ -265,7 +265,7 @@ def _check_storage_event( f"storage event {event.name} refers to storage {storage.name} " f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", ) - elif storage not in state.storage: + elif storage not in state.storages: errors.append( f"cannot emit {event.name} because storage {storage.name} " f"is not in the state.", @@ -330,11 +330,11 @@ def check_storages_consistency( **_kwargs, # noqa: U101 ) -> Results: """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" - state_storage = state.storage + state_storage = state.storages meta_storage = (charm_spec.meta or {}).get("storage", {}) errors = [] - if missing := {s.name for s in state.storage}.difference( + if missing := {s.name for s in state_storage}.difference( set(meta_storage.keys()), ): errors.append( @@ -347,7 +347,7 @@ def check_storages_consistency( if tag in seen: errors.append( f"duplicate storage in State: storage {s.name} with index {s.index} " - f"occurs multiple times in State.storage.", + f"occurs multiple times in State.storages.", ) seen.append(tag) @@ -465,10 +465,8 @@ def check_network_consistency( if metadata.get("scope") != "container" # mark of a sub } - state_bindings = set(state.networks) - if diff := state_bindings.difference( - meta_bindings.union(non_sub_relations).union(implicit_bindings), - ): + 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}.", ) @@ -598,11 +596,6 @@ def check_containers_consistency( f"Missing from metadata: {diff}.", ) - # guard against duplicate container names - names = Counter(state_containers) - if dupes := [n for n in names if names[n] > 1]: - errors.append(f"Duplicate container name(s): {dupes}.") - return Results(errors, []) @@ -633,12 +626,12 @@ def check_storedstate_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of `state.storedstate`.""" + """Check the internal consistency of `state.stored_states`.""" errors = [] # Attribute names must be unique on each object. names = defaultdict(list) - for ss in state.stored_state: + for ss in state.stored_states: names[ss.owner_path].append(ss.name) for owner, owner_names in names.items(): if len(owner_names) != len(set(owner_names)): @@ -647,7 +640,7 @@ def check_storedstate_consistency( ) # The content must be marshallable. - for ss in state.stored_state: + for ss in state.stored_states: # We don't need the marshalled state, just to know that it can be. # This is the same "only simple types" check that ops does. try: diff --git a/scenario/mocking.py b/scenario/mocking.py index 71570de1..a8dae087 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -129,9 +129,11 @@ def open_port( # fixme: the charm will get hit with a StateValidationError # here, not the expected ModelError... port_ = _port_cls_by_protocol[protocol](port=port) - ports = self._state.opened_ports + ports = set(self._state.opened_ports) if port_ not in ports: - ports.append(port_) + ports.add(port_) + if ports != self._state.opened_ports: + self._state._update_opened_ports(frozenset(ports)) def close_port( self, @@ -139,9 +141,11 @@ def close_port( port: Optional[int] = None, ): _port = _port_cls_by_protocol[protocol](port=port) - ports = self._state.opened_ports + ports = set(self._state.opened_ports) if _port in ports: ports.remove(_port) + if ports != self._state.opened_ports: + self._state._update_opened_ports(frozenset(ports)) def get_pebble(self, socket_path: str) -> "Client": container_name = socket_path.split("/")[ @@ -150,7 +154,7 @@ def get_pebble(self, socket_path: str) -> "Client": container_root = self._context._get_container_root(container_name) try: mounts = self._state.get_container(container_name).mounts - except ValueError: + except KeyError: # container not defined in state. mounts = {} @@ -168,11 +172,9 @@ def _get_relation_by_id( rel_id, ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: - return next( - filter(lambda r: r.id == rel_id, self._state.relations), - ) - except StopIteration: - raise RelationNotFoundError() + return self._state.get_relation(rel_id) + except ValueError: + raise RelationNotFoundError() from None def _get_secret(self, id=None, label=None): # FIXME: what error would a charm get IRL? @@ -314,7 +316,10 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): raise RelationNotFoundError() # We look in State.networks for an override. If not given, we return a default network. - network = self._state.networks.get(binding_name, Network.default()) + try: + network = self._state.get_network(binding_name) + except KeyError: + network = Network.default("default") # The name is not used in the output. return network.hook_tool_output_fmt() # setter methods: these can mutate the state. @@ -374,7 +379,9 @@ def secret_add( rotate=rotate, owner=owner, ) - self._state.secrets.append(secret) + secrets = set(self._state.secrets) + secrets.add(secret) + self._state._update_secrets(frozenset(secrets)) return secret_id def _check_can_manage_secret( @@ -560,7 +567,7 @@ def storage_add(self, name: str, count: int = 1): def storage_list(self, name: str) -> List[int]: return [ - storage.index for storage in self._state.storage if storage.name == name + storage.index for storage in self._state.storages if storage.name == name ] def _storage_event_details(self) -> Tuple[int, str]: @@ -587,7 +594,7 @@ def storage_get(self, storage_name_id: str, attribute: str) -> str: name, index = storage_name_id.split("/") index = int(index) storages: List[Storage] = [ - s for s in self._state.storage if s.name == name and s.index == index + s for s in self._state.storages if s.name == name and s.index == index ] # should not really happen: sanity checks. In practice, ops will guard against these paths. @@ -627,16 +634,19 @@ def add_metrics( "it's deprecated API)", ) + # TODO: It seems like this method has no tests. def resource_get(self, resource_name: str) -> str: - try: - return str(self._state.resources[resource_name]) - except KeyError: - # ops will not let us get there if the resource name is unknown from metadata. - # but if the user forgot to add it in State, then we remind you of that. - raise RuntimeError( - f"Inconsistent state: " - f"resource {resource_name} not found in State. please pass it.", - ) + # We assume that there are few enough resources that a linear search + # will perform well enough. + for resource in self._state.resources: + if resource.name == resource_name: + return str(resource.path) + # ops will not let us get there if the resource name is unknown from metadata. + # but if the user forgot to add it in State, then we remind you of that. + raise RuntimeError( + f"Inconsistent state: " + f"resource {resource_name} not found in State. please pass it.", + ) def credential_get(self) -> CloudSpec_Ops: if not self._context.app_trusted: diff --git a/scenario/runtime.py b/scenario/runtime.py index 97a7c773..97abe921 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,7 +10,7 @@ import typing from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Type, Union +from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, Union import yaml from ops import pebble @@ -62,12 +62,12 @@ def _open_db(self) -> SQLiteStorage: """Open the db.""" return SQLiteStorage(self._state_file) - def get_stored_state(self) -> List["StoredState"]: + def get_stored_states(self) -> FrozenSet["StoredState"]: """Load any StoredState data structures from the db.""" db = self._open_db() - stored_state = [] + stored_states = set() for handle_path in db.list_snapshots(): if not EVENT_REGEX.match(handle_path) and ( match := STORED_STATE_REGEX.match(handle_path) @@ -75,10 +75,10 @@ def get_stored_state(self) -> List["StoredState"]: stored_state_snapshot = db.load_snapshot(handle_path) kwargs = match.groupdict() sst = StoredState(content=stored_state_snapshot, **kwargs) - stored_state.append(sst) + stored_states.add(sst) db.close() - return stored_state + return frozenset(stored_states) def get_deferred_events(self) -> List["DeferredEvent"]: """Load any DeferredEvent data structures from the db.""" @@ -119,7 +119,7 @@ def apply_state(self, state: "State"): ) from e db.save_snapshot(event.handle_path, event.snapshot_data) - for stored_state in state.stored_state: + for stored_state in state.stored_states: db.save_snapshot(stored_state.handle_path, stored_state.content) db.close() @@ -347,7 +347,7 @@ def _virtual_charm_root(self): elif ( not spec.is_autoloaded and any_metadata_files_present_in_charm_virtual_root ): - logger.warn( + logger.warning( f"Some metadata files found in custom user-provided charm_root " f"{charm_virtual_root} while you have passed meta, config or actions to " f"Context.run(). " @@ -388,8 +388,8 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): """Now that we're done processing this event, read the charm state and expose it.""" store = self._get_state_db(temporary_charm_root) deferred = store.get_deferred_events() - stored_state = store.get_stored_state() - return dataclasses.replace(state, deferred=deferred, stored_state=stored_state) + stored_state = store.get_stored_states() + return dataclasses.replace(state, deferred=deferred, stored_states=stored_state) @contextmanager def _exec_ctx(self, ctx: "Context"): diff --git a/scenario/state.py b/scenario/state.py index c788e855..12d7d301 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -18,6 +18,7 @@ Callable, Dict, Final, + FrozenSet, Generic, List, Literal, @@ -291,6 +292,9 @@ class Secret(_max_posargs(1)): expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None + def __hash__(self) -> int: + return hash(self.id) + def _set_revision(self, revision: int): """Set a new tracked revision.""" # bypass frozen dataclass @@ -370,11 +374,15 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network(_max_posargs(0)): +class Network(_max_posargs(1)): + binding_name: str bind_addresses: List[BindAddress] ingress_addresses: List[str] egress_subnets: List[str] + def __hash__(self) -> int: + return hash(self.binding_name) + def hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would return { @@ -386,6 +394,7 @@ def hook_tool_output_fmt(self): @classmethod def default( cls, + binding_name: str, private_address: str = "192.0.2.0", hostname: str = "", cidr: str = "", @@ -396,6 +405,7 @@ def default( ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( + binding_name=binding_name, bind_addresses=[ BindAddress( interface_name=interface_name, @@ -470,6 +480,9 @@ def __post_init__(self): for databag in self._databags: self._validate_databag(databag) + def __hash__(self) -> int: + return hash(self.id) + def _validate_databag(self, databag: dict): if not isinstance(databag, dict): raise StateValidationError( @@ -508,6 +521,9 @@ class Relation(RelationBase): ) """The current content of the databag for each unit in the relation.""" + def __hash__(self) -> int: + return hash(self.id) + @property def _remote_app_name(self) -> str: """Who is on the other end of this relation?""" @@ -542,6 +558,9 @@ class SubordinateRelation(_RelationBase): remote_app_name: str = "remote" remote_unit_id: int = 0 + def __hash__(self) -> int: + return hash(self.id) + @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" @@ -579,6 +598,9 @@ class PeerRelation(RelationBase): """Current contents of the peer databags.""" # Consistency checks will validate that *this unit*'s ID is not in here. + def __hash__(self) -> int: + return hash(self.id) + @property def _databags(self): """Yield all databags in this relation.""" @@ -837,6 +859,9 @@ class Container(_max_posargs(1)): notices: List[Notice] = dataclasses.field(default_factory=list) + def __hash__(self) -> int: + return hash(self.name) + def _render_services(self): # copied over from ops.testing._TestingPebbleClient._render_services() services = {} # type: Dict[str, pebble.Service] @@ -984,6 +1009,9 @@ class StoredState(_max_posargs(1)): 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) + _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] @@ -1089,6 +1117,14 @@ def get_filesystem(self, ctx: "Context") -> Path: return ctx._get_storage_root(self.name, self.index) +@dataclasses.dataclass(frozen=True) +class Resource(_max_posargs(0)): + """Represents a resource made available to the charm.""" + + name: str + path: "PathLike" + + @dataclasses.dataclass(frozen=True) class State(_max_posargs(0)): """Represents the juju-owned portion of a unit's state. @@ -1102,9 +1138,9 @@ class State(_max_posargs(0)): default_factory=dict, ) """The present configuration of this charm.""" - relations: List["AnyRelation"] = dataclasses.field(default_factory=list) + relations: FrozenSet["AnyRelation"] = dataclasses.field(default_factory=frozenset) """All relations that currently exist for this charm.""" - networks: Dict[str, Network] = dataclasses.field(default_factory=dict) + networks: FrozenSet[Network] = dataclasses.field(default_factory=frozenset) """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. @@ -1112,36 +1148,38 @@ class State(_max_posargs(0)): support it, but use at your own risk.] If a metadata-defined extra-binding is left empty, it will be defaulted. """ - containers: List[Container] = dataclasses.field(default_factory=list) + containers: FrozenSet[Container] = dataclasses.field(default_factory=frozenset) """All containers (whether they can connect or not) that this charm is aware of.""" - storage: List[Storage] = dataclasses.field(default_factory=list) + storages: FrozenSet[Storage] = dataclasses.field(default_factory=frozenset) """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: List[_Port] = dataclasses.field(default_factory=list) + opened_ports: FrozenSet[_Port] = dataclasses.field(default_factory=frozenset) """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: List[Secret] = dataclasses.field(default_factory=list) + secrets: FrozenSet[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: Dict[str, "PathLike"] = dataclasses.field(default_factory=dict) - """Mapping from resource name to path at which the resource can be found.""" + resources: FrozenSet[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 + # Represents the OF's event queue. These events will be emitted before the event being # dispatched, and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) """Events that have been deferred on this charm by some previous execution.""" - stored_state: List["StoredState"] = dataclasses.field(default_factory=list) + stored_states: FrozenSet["StoredState"] = dataclasses.field( + default_factory=frozenset, + ) """Contents of a charm's stored state.""" # the current statuses. Will be cast to _EntitiyStatus in __post_init__ @@ -1161,6 +1199,24 @@ def __post_init__(self): object.__setattr__(self, name, _status_to_entitystatus(val)) else: raise TypeError(f"Invalid status.{name}: {val!r}") + # It's convenient to pass a set, but we really want the attributes to be + # frozen sets to increase the immutability of State objects. + for name in [ + "relations", + "containers", + "storages", + "networks", + "opened_ports", + "secrets", + "resources", + "stored_states", + ]: + val = getattr(self, name) + # We check for "not frozenset" rather than "is set" so that you can + # actually pass a tuple or list or really any iterable of hashable + # objects, and it will end up as a frozenset. + if not isinstance(val, frozenset): + object.__setattr__(self, name, frozenset(val)) def _update_workload_version(self, new_workload_version: str): """Update the current app version and record the previous one.""" @@ -1181,6 +1237,16 @@ def _update_status( # bypass frozen dataclass object.__setattr__(self, name, _EntityStatus(new_status, new_message)) + def _update_opened_ports(self, new_ports: FrozenSet[_Port]): + """Update the current opened ports.""" + # bypass frozen dataclass + object.__setattr__(self, "opened_ports", new_ports) + + def _update_secrets(self, new_secrets: FrozenSet[Secret]): + """Update the current secrets.""" + # bypass frozen dataclass + object.__setattr__(self, "secrets", new_secrets) + def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: @@ -1202,15 +1268,73 @@ def with_unit_status(self, status: StatusBase) -> "State": ), ) - def get_container(self, container: Union[str, Container]) -> Container: - """Get container from this State, based on an input container or its name.""" - container_name = ( - container.name if isinstance(container, Container) else container + def get_container(self, container: str, /) -> Container: + """Get container from this State, based on its name.""" + for state_container in self.containers: + if state_container.name == container: + return state_container + raise KeyError(f"container: {container} not found in the State") + + def get_network(self, binding_name: str, /) -> Network: + """Get network from this State, based on its binding name.""" + for network in self.networks: + if network.binding_name == binding_name: + return network + raise KeyError(f"network: {binding_name} not found in the State") + + def get_secret( + self, + *, + id: Optional[str] = None, + label: Optional[str] = None, + ) -> Secret: + """Get secret from this State, based on the secret's id or label.""" + if id is None and label is None: + raise ValueError("An id or label must be provided.") + + for secret in self.secrets: + if ( + (id and label and secret.id == id and secret.label == label) + or (id and label is None and secret.id == id) + or (id is None and label and secret.label == label) + ): + return secret + raise KeyError("secret: not found in the State") + + def get_stored_state( + self, + stored_state: str, + /, + *, + owner_path: Optional[str] = None, + ) -> StoredState: + """Get stored state from this State, based on the stored state's name and owner_path.""" + for ss in self.stored_states: + if ss.name == stored_state and ss.owner_path == owner_path: + return ss + raise ValueError(f"stored state: {stored_state} not found in the State") + + def get_storage( + self, + storage: str, + /, + *, + index: Optional[int] = 0, + ) -> Storage: + """Get storage from this State, based on the storage's name and index.""" + for state_storage in self.storages: + if state_storage.name == storage and storage.index == index: + return state_storage + raise ValueError( + f"storage: name={storage}, index={index} not found in the State", ) - containers = [c for c in self.containers if c.name == container_name] - if not containers: - raise ValueError(f"container: {container_name} not found in the State") - return containers[0] + + def get_relation(self, relation: int, /) -> "AnyRelation": + """Get relation from this State, based on the relation's id.""" + for state_relation in self.relations: + if state_relation.id == relation: + return state_relation + raise KeyError(f"relation: id={relation} not found in the State") def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: """Get all relations on this endpoint from the current state.""" @@ -1227,10 +1351,6 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: if normalize_name(r.endpoint) == normalized_endpoint ) - def get_storages(self, name: str) -> Tuple["Storage", ...]: - """Get all storages with this name.""" - return tuple(s for s in self.storage if s.name == name) - def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): # Check whether this dict has the expected mandatory metadata fields according to the diff --git a/tests/helpers.py b/tests/helpers.py index 7dd1f835..c8060d1c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -52,10 +52,10 @@ def trigger( if isinstance(event, str): if event.startswith("relation_"): assert len(state.relations) == 1, "shortcut only works with one relation" - event = getattr(ctx.on, event)(state.relations[0]) + event = getattr(ctx.on, event)(tuple(state.relations)[0]) elif event.startswith("pebble_"): assert len(state.containers) == 1, "shortcut only works with one container" - event = getattr(ctx.on, event)(state.containers[0]) + event = getattr(ctx.on, event)(tuple(state.containers)[0]) else: event = getattr(ctx.on, event)() with ctx.manager(event, state=state) as mgr: @@ -67,11 +67,22 @@ def trigger( return state_out -def jsonpatch_delta(input: "State", output: "State"): - patch = jsonpatch.make_patch( - dataclasses.asdict(output), - dataclasses.asdict(input), - ).patch +def jsonpatch_delta(self, other: "State"): + dict_other = dataclasses.asdict(other) + dict_self = dataclasses.asdict(self) + for attr in ( + "relations", + "containers", + "storages", + "opened_ports", + "secrets", + "resources", + "stored_states", + "networks", + ): + dict_other[attr] = [dataclasses.asdict(o) for o in dict_other[attr]] + dict_self[attr] = [dataclasses.asdict(o) for o in dict_self[attr]] + patch = jsonpatch.make_patch(dict_other, dict_self).patch return sort_patch(patch) diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 51ba1391..fb738f87 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -144,7 +144,7 @@ def test_relations_ok(tmp_path, legacy): ) as charm: # this would fail if there were no 'cuddles' relation defined in meta ctx = Context(charm) - ctx.run(ctx.on.start(), State(relations=[Relation("cuddles")])) + ctx.run(ctx.on.start(), State(relations={Relation("cuddles")})) @pytest.mark.parametrize("legacy", (True, False)) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 82321558..82d9c76a 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -16,6 +16,7 @@ Notice, PeerRelation, Relation, + Resource, Secret, State, Storage, @@ -63,7 +64,7 @@ def test_workload_event_without_container(): _CharmSpec(MyCharm, {}), ) assert_consistent( - State(containers=[Container("foo")]), + State(containers={Container("foo")}), _Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -74,12 +75,12 @@ def test_workload_event_without_container(): ) notice = Notice("example.com/foo") assert_consistent( - State(containers=[Container("foo", notices=[notice])]), + State(containers={Container("foo", notices=[notice])}), _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) assert_inconsistent( - State(containers=[Container("foo")]), + State(containers={Container("foo")}), _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -87,12 +88,12 @@ def test_workload_event_without_container(): def test_container_meta_mismatch(): assert_inconsistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {"containers": {"baz": {}}}), ) assert_consistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -100,12 +101,12 @@ def test_container_meta_mismatch(): def test_container_in_state_but_no_container_in_meta(): assert_inconsistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {}), ) assert_consistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -119,7 +120,7 @@ def test_container_not_in_state(): _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) assert_consistent( - State(containers=[container]), + State(containers={container}), _Event("bar_pebble_ready", container=container), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -132,7 +133,7 @@ def test_evt_bad_container_name(): _CharmSpec(MyCharm, {}), ) assert_consistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("bar-pebble-ready", container=Container("bar")), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -147,7 +148,7 @@ def test_evt_bad_relation_name(suffix): ) relation = Relation("bar") assert_consistent( - State(relations=[relation]), + State(relations={relation}), _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @@ -158,7 +159,7 @@ def test_evt_no_relation(suffix): assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) relation = Relation("bar") assert_consistent( - State(relations=[relation]), + State(relations={relation}), _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @@ -262,13 +263,13 @@ def test_config_secret_old_juju(juju_version): def test_secrets_jujuv_bad(bad_v): secret = Secret("secret:foo", {0: {"a": "b"}}) assert_inconsistent( - State(secrets=[secret]), + State(secrets={secret}), _Event("bar"), _CharmSpec(MyCharm, {}), bad_v, ) assert_inconsistent( - State(secrets=[secret]), + State(secrets={secret}), secret.changed_event, _CharmSpec(MyCharm, {}), bad_v, @@ -285,7 +286,7 @@ def test_secrets_jujuv_bad(bad_v): @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) def test_secrets_jujuv_bad(good_v): assert_consistent( - State(secrets=[Secret(id="secret:foo", contents={0: {"a": "b"}})]), + State(secrets={Secret(id="secret:foo", contents={0: {"a": "b"}})}), _Event("bar"), _CharmSpec(MyCharm, {}), good_v, @@ -308,12 +309,12 @@ def test_secret_not_in_state(): def test_peer_relation_consistency(): assert_inconsistent( - State(relations=[Relation("foo")]), + State(relations={Relation("foo")}), _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) assert_consistent( - State(relations=[PeerRelation("foo")]), + State(relations={PeerRelation("foo")}), _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) @@ -335,7 +336,7 @@ def test_duplicate_endpoints_inconsistent(): def test_sub_relation_consistency(): assert_inconsistent( - State(relations=[Relation("foo")]), + State(relations={Relation("foo")}), _Event("bar"), _CharmSpec( MyCharm, @@ -344,7 +345,7 @@ def test_sub_relation_consistency(): ) assert_consistent( - State(relations=[SubordinateRelation("foo")]), + State(relations={SubordinateRelation("foo")}), _Event("bar"), _CharmSpec( MyCharm, @@ -355,7 +356,7 @@ def test_sub_relation_consistency(): def test_relation_sub_inconsistent(): assert_inconsistent( - State(relations=[SubordinateRelation("foo")]), + State(relations={SubordinateRelation("foo")}), _Event("bar"), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) @@ -369,20 +370,12 @@ def test_relation_not_in_state(): _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) assert_consistent( - State(relations=[relation]), + State(relations={relation}), _Event("foo_relation_changed", relation=relation), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) -def test_dupe_containers_inconsistent(): - assert_inconsistent( - State(containers=[Container("foo"), Container("foo")]), - _Event("bar"), - _CharmSpec(MyCharm, {"containers": {"foo": {}}}), - ) - - def test_action_not_in_meta_inconsistent(): action = Action("foo", params={"bar": "baz"}) assert_inconsistent( @@ -459,7 +452,7 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), + State(relations={Relation("foo", id=1), Relation("bar", id=1)}), _Event("start"), _CharmSpec( MyCharm, @@ -472,13 +465,13 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), + State(relations={Relation("foo", id=1), Relation("bar", id=1)}), _Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=2)]), + State(relations={Relation("foo", id=1), Relation("bar", id=2)}), _Event("start"), _CharmSpec( MyCharm, @@ -492,12 +485,12 @@ def test_relation_without_endpoint(): def test_storage_event(): storage = Storage("foo") assert_inconsistent( - State(storage=[storage]), + State(storages={storage}), _Event("foo-storage-attached"), _CharmSpec(MyCharm, meta={"name": "rupert"}), ) assert_inconsistent( - State(storage=[storage]), + State(storages={storage}), _Event("foo-storage-attached"), _CharmSpec( MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} @@ -510,19 +503,19 @@ def test_storage_states(): storage2 = Storage("foo", index=1) assert_inconsistent( - State(storage=[storage1, storage2]), + State(storages={storage1, storage2}), _Event("start"), _CharmSpec(MyCharm, meta={"name": "everett"}), ) assert_consistent( - State(storage=[storage1, dataclasses.replace(storage2, index=2)]), + State(storages={storage1, dataclasses.replace(storage2, index=2)}), _Event("start"), _CharmSpec( MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} ), ) assert_consistent( - State(storage=[storage1, dataclasses.replace(storage2, name="marx")]), + State(storages={storage1, dataclasses.replace(storage2, name="marx")}), _Event("start"), _CharmSpec( MyCharm, @@ -548,7 +541,7 @@ def test_storage_not_in_state(): ), ) assert_consistent( - State(storage=[storage]), + State(storages=[storage]), _Event("foo_storage_attached", storage=storage), _CharmSpec( MyCharm, @@ -560,7 +553,7 @@ def test_storage_not_in_state(): def test_resource_states(): # happy path assert_consistent( - State(resources={"foo": "/foo/bar.yaml"}), + State(resources={Resource(name="foo", path="/foo/bar.yaml")}), _Event("start"), _CharmSpec( MyCharm, @@ -580,7 +573,7 @@ def test_resource_states(): # resource not defined in meta assert_inconsistent( - State(resources={"bar": "/foo/bar.yaml"}), + State(resources={Resource(name="bar", path="/foo/bar.yaml")}), _Event("start"), _CharmSpec( MyCharm, @@ -589,7 +582,7 @@ def test_resource_states(): ) assert_inconsistent( - State(resources={"bar": "/foo/bar.yaml"}), + State(resources={Resource(name="bar", path="/foo/bar.yaml")}), _Event("start"), _CharmSpec( MyCharm, @@ -600,7 +593,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( - State(networks={"foo": Network.default()}), + State(networks={Network.default("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -609,7 +602,7 @@ def test_networks_consistency(): ) assert_inconsistent( - State(networks={"foo": Network.default()}), + State(networks={Network.default("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -622,7 +615,7 @@ def test_networks_consistency(): ) assert_consistent( - State(networks={"foo": Network.default()}), + State(networks={Network.default("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -672,27 +665,12 @@ def test_cloudspec_consistency(): def test_storedstate_consistency(): assert_consistent( State( - stored_state=[ + stored_states={ StoredState(content={"foo": "bar"}), StoredState(name="my_stored_state", content={"foo": 1}), StoredState(owner_path="MyCharmLib", content={"foo": None}), StoredState(owner_path="OtherCharmLib", content={"foo": (1, 2, 3)}), - ] - ), - _Event("start"), - _CharmSpec( - MyCharm, - meta={ - "name": "foo", - }, - ), - ) - assert_inconsistent( - State( - stored_state=[ - StoredState(owner_path=None, content={"foo": "bar"}), - StoredState(owner_path=None, name="_stored", content={"foo": "bar"}), - ] + } ), _Event("start"), _CharmSpec( @@ -704,11 +682,11 @@ def test_storedstate_consistency(): ) assert_inconsistent( State( - stored_state=[ + stored_states={ StoredState( owner_path=None, content={"secret": Secret(id="foo", contents={})} ) - ] + } ), _Event("start"), _CharmSpec( diff --git a/tests/test_context_on.py b/tests/test_context_on.py index d9609d2e..1c98b4ea 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -156,7 +156,7 @@ def test_revision_secret_events_as_positional_arg(event_name): def test_storage_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) storage = scenario.Storage("foo") - state_in = scenario.State(storage=[storage]) + state_in = scenario.State(storages=[storage]) # These look like: # ctx.run(ctx.on.storage_attached(storage), state) with ctx.manager(getattr(ctx.on, event_name)(storage), state_in) as mgr: diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index fccb326c..f988dcc5 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -120,7 +120,7 @@ def test_deferred_relation_event(mycharm): out = trigger( State( - relations=[rel], + relations={rel}, deferred=[ deferred( event="foo_relation_changed", @@ -152,7 +152,7 @@ def test_deferred_relation_event_from_relation(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") out = trigger( State( - relations=[rel], + relations={rel}, deferred=[ ctx.on.relation_changed(rel, remote_unit=1).deferred( handler=mycharm._on_event @@ -190,7 +190,7 @@ def test_deferred_workload_event(mycharm): out = trigger( State( - containers=[ctr], + containers={ctr}, deferred=[ _Event("foo_pebble_ready", container=ctr).deferred( handler=mycharm._on_event @@ -238,7 +238,7 @@ def test_defer_reemit_relation_event(mycharm): rel = Relation("foo") mycharm.defer_next = 1 - state_1 = ctx.run(ctx.on.relation_created(rel), State(relations=[rel])) + state_1 = ctx.run(ctx.on.relation_created(rel), State(relations={rel})) mycharm.defer_next = 0 state_2 = ctx.run(ctx.on.start(), state_1) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 68324647..47302698 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -51,7 +51,7 @@ def test_ip_get(mycharm): id=1, ), ], - networks={"foo": Network.default(private_address="4.4.4.4")}, + networks={Network.default("foo", private_address="4.4.4.4")}, ), ) as mgr: # we have a network for the relation @@ -113,7 +113,7 @@ def test_no_relation_error(mycharm): id=1, ), ], - networks={"bar": Network.default()}, + networks={Network.default("bar")}, ), ) as mgr: with pytest.raises(RelationNotFoundError): diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 7dfbba67..08acebc3 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -61,7 +61,7 @@ def callback(self: CharmBase): assert can_connect == self.unit.get_container("foo").can_connect() trigger( - State(containers=[Container(name="foo", can_connect=can_connect)]), + State(containers={Container(name="foo", can_connect=can_connect)}), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -82,13 +82,13 @@ def callback(self: CharmBase): trigger( State( - containers=[ + containers={ Container( name="foo", can_connect=True, mounts={"bar": Mount(location="/bar/baz.txt", source=pth)}, ) - ] + } ), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, @@ -122,7 +122,7 @@ def callback(self: CharmBase): can_connect=True, mounts={"foo": Mount(location="/foo", source=td.name)}, ) - state = State(containers=[container]) + state = State(containers={container}) ctx = Context( charm_type=charm_cls, @@ -156,7 +156,7 @@ def callback(self: CharmBase): else: # nothing has changed - out_purged = dataclasses.replace(out, stored_state=state.stored_state) + out_purged = dataclasses.replace(out, stored_states=state.stored_states) assert not jsonpatch_delta(out_purged, state) @@ -197,13 +197,13 @@ def callback(self: CharmBase): trigger( State( - containers=[ + containers={ Container( name="foo", can_connect=True, exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, ) - ] + } ), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, @@ -220,7 +220,7 @@ def callback(self: CharmBase): container = Container(name="foo", can_connect=True) trigger( - State(containers=[container]), + State(containers={container}), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="pebble_ready", @@ -287,14 +287,14 @@ def _on_ready(self, event): ) out = trigger( - State(containers=[container]), + State(containers={container}), charm_type=PlanCharm, meta={"name": "foo", "containers": {"foo": {}}}, event="pebble_ready", ) serv = lambda name, obj: pebble.Service(name, raw=obj) - container = out.containers[0] + container = out.get_container(container.name) assert container.plan.services == { "barserv": serv("barserv", {"startup": "disabled"}), "fooserv": serv("fooserv", {"startup": "enabled"}), @@ -308,13 +308,13 @@ def _on_ready(self, event): def test_exec_wait_error(charm_cls): state = State( - containers=[ + containers={ Container( name="foo", can_connect=True, exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, ) - ] + } ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) @@ -328,7 +328,7 @@ def test_exec_wait_error(charm_cls): def test_exec_wait_output(charm_cls): state = State( - containers=[ + containers={ Container( name="foo", can_connect=True, @@ -336,7 +336,7 @@ def test_exec_wait_output(charm_cls): ("foo",): ExecOutput(stdout="hello pebble", stderr="oepsie") }, ) - ] + } ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) @@ -350,13 +350,13 @@ def test_exec_wait_output(charm_cls): def test_exec_wait_output_error(charm_cls): state = State( - containers=[ + containers={ Container( name="foo", can_connect=True, exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, ) - ] + } ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 7fe07899..103940af 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -62,7 +62,7 @@ def post_event(charm): assert out.unit_status == ActiveStatus("yabadoodle") - out_purged = dataclasses.replace(out, stored_state=initial_state.stored_state) + out_purged = dataclasses.replace(out, stored_states=initial_state.stored_states) assert jsonpatch_delta(out_purged, initial_state) == [ { "op": "replace", @@ -100,7 +100,7 @@ def check_relation_data(charm): assert remote_app_data == {"yaba": "doodle"} state_in = State( - relations=[ + relations={ Relation( endpoint="relation_test", interface="azdrubales", @@ -109,7 +109,7 @@ def check_relation_data(charm): remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, ) - ] + } ) trigger( state_in, diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index 3a19148f..80365a01 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -28,14 +28,15 @@ def ctx(): def test_open_port(ctx): out = ctx.run(ctx.on.start(), State()) - port = out.opened_ports.pop() + assert len(out.opened_ports) == 1 + port = tuple(out.opened_ports)[0] assert port.protocol == "tcp" assert port.port == 12 def test_close_port(ctx): - out = ctx.run(ctx.on.stop(), State(opened_ports=[TCPPort(42)])) + out = ctx.run(ctx.on.stop(), State(opened_ports={TCPPort(42)})) assert not out.opened_ports diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 853c7ba5..9ba0ed61 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -64,10 +64,10 @@ def pre_event(charm: CharmBase): State( config={"foo": "bar"}, leader=True, - relations=[ + relations={ Relation(endpoint="foo", interface="foo", remote_app_name="remote"), Relation(endpoint="qux", interface="qux", remote_app_name="remote"), - ], + }, ), "start", mycharm, @@ -97,9 +97,9 @@ def test_relation_events(mycharm, evt_name): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -141,9 +141,9 @@ def callback(charm: CharmBase, e): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -202,7 +202,7 @@ def callback(charm: CharmBase, event): }, }, ) - state = State(relations=[relation]) + state = State(relations={relation}) kwargs = {} if has_unit: kwargs["remote_unit"] = remote_unit_id @@ -242,9 +242,9 @@ def callback(charm: CharmBase, event): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -302,9 +302,9 @@ def callback(charm: CharmBase, event): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -356,7 +356,7 @@ def test_relation_event_trigger(relation, evt_name, mycharm): "peers": {"b": {"interface": "i2"}}, } state = trigger( - State(relations=[relation]), + State(relations={relation}), f"relation_{evt_name}", mycharm, meta=meta, @@ -389,7 +389,7 @@ def post_event(charm: CharmBase): assert len(relation.units) == 1 trigger( - State(relations=[sub1, sub2]), + State(relations={sub1, sub2}), "update_status", mycharm, meta=meta, @@ -417,7 +417,7 @@ def test_broken_relation_not_in_model_relations(mycharm): ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} ) - with ctx.manager(ctx.on.relation_broken(rel), state=State(relations=[rel])) as mgr: + with ctx.manager(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: charm = mgr.charm assert charm.model.get_relation("foo") is None diff --git a/tests/test_e2e/test_resource.py b/tests/test_e2e/test_resource.py new file mode 100644 index 00000000..c4237ea6 --- /dev/null +++ b/tests/test_e2e/test_resource.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +import pathlib + +import ops +import pytest + +from scenario import Context, Resource, State + + +class ResourceCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + + +def test_get_resource(): + ctx = Context( + ResourceCharm, + meta={ + "name": "resource-charm", + "resources": {"foo": {"type": "file"}, "bar": {"type": "file"}}, + }, + ) + resource1 = Resource(name="foo", path=pathlib.Path("/tmp/foo")) + resource2 = Resource(name="bar", path=pathlib.Path("~/bar")) + with ctx.manager( + ctx.on.update_status(), state=State(resources={resource1, resource2}) + ) as mgr: + assert mgr.charm.model.resources.fetch("foo") == resource1.path + assert mgr.charm.model.resources.fetch("bar") == resource2.path + with pytest.raises(NameError): + mgr.charm.model.resources.fetch("baz") diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 5958781c..a9a3697e 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -45,7 +45,7 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): ctx = Context(mycharm, meta={"name": "local"}) with ctx.manager( - state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + state=State(secrets={Secret(id="foo", contents={0: {"a": "b"}})}), event=ctx.on.update_status(), ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" @@ -57,7 +57,7 @@ def test_get_secret_get_refresh(mycharm, owner): with ctx.manager( ctx.on.update_status(), State( - secrets=[ + secrets={ Secret( id="foo", contents={ @@ -66,7 +66,7 @@ def test_get_secret_get_refresh(mycharm, owner): }, owner=owner, ) - ] + } ), ) as mgr: charm = mgr.charm @@ -80,7 +80,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): ctx.on.update_status(), State( leader=app, - secrets=[ + secrets={ Secret( id="foo", contents={ @@ -88,7 +88,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): 1: {"a": "c"}, }, ), - ], + }, ), ) as mgr: charm = mgr.charm @@ -106,7 +106,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): with ctx.manager( ctx.on.update_status(), State( - secrets=[ + secrets={ Secret( id="foo", contents={ @@ -115,7 +115,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): }, owner=owner, ) - ] + } ), ) as mgr: charm = mgr.charm @@ -177,7 +177,7 @@ def test_add(mycharm, app): charm.unit.add_secret({"foo": "bar"}, label="mylabel") assert mgr.output.secrets - secret = mgr.output.secrets[0] + secret = mgr.output.get_secret(label="mylabel") assert secret.contents[0] == {"foo": "bar"} assert secret.label == "mylabel" @@ -221,7 +221,7 @@ def test_set_legacy_behaviour(mycharm): == rev3 ) - assert state_out.secrets[0].contents == { + assert state_out.get_secret(label="mylabel").contents == { 0: rev1, 1: rev2, 2: rev3, @@ -253,7 +253,7 @@ def test_set(mycharm): assert secret.get_content() == rev2 assert secret.peek_content() == secret.get_content(refresh=True) == rev3 - assert state_out.secrets[0].contents == { + assert state_out.get_secret(label="mylabel").contents == { 0: rev1, 1: rev2, 2: rev3, @@ -282,7 +282,7 @@ def test_set_juju33(mycharm): assert secret.peek_content() == rev3 assert secret.get_content(refresh=True) == rev3 - assert state_out.secrets[0].contents == { + assert state_out.get_secret(label="mylabel").contents == { 0: rev1, 1: rev2, 2: rev3, @@ -296,7 +296,7 @@ def test_meta(mycharm, app): ctx.on.update_status(), State( leader=True, - secrets=[ + secrets={ Secret( owner="app" if app else "unit", id="foo", @@ -307,7 +307,7 @@ def test_meta(mycharm, app): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: charm = mgr.charm @@ -336,7 +336,7 @@ def test_secret_permission_model(mycharm, leader, owner): ctx.on.update_status(), State( leader=leader, - secrets=[ + secrets={ Secret( id="foo", label="mylabel", @@ -347,7 +347,7 @@ def test_secret_permission_model(mycharm, leader, owner): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: secret = mgr.charm.model.get_secret(id="foo") @@ -389,7 +389,7 @@ def test_grant(mycharm, app): ctx.on.update_status(), State( relations=[Relation("foo", "remote")], - secrets=[ + secrets={ Secret( owner="unit", id="foo", @@ -400,7 +400,7 @@ def test_grant(mycharm, app): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: charm = mgr.charm @@ -410,7 +410,7 @@ def test_grant(mycharm, app): secret.grant(relation=foo) else: secret.grant(relation=foo, unit=foo.units.pop()) - vals = list(mgr.output.secrets[0].remote_grants.values()) + vals = list(mgr.output.get_secret(label="mylabel").remote_grants.values()) assert vals == [{"remote"}] if app else [{"remote/0"}] @@ -421,7 +421,7 @@ def test_update_metadata(mycharm): with ctx.manager( ctx.on.update_status(), State( - secrets=[ + secrets={ Secret( owner="unit", id="foo", @@ -430,7 +430,7 @@ def test_update_metadata(mycharm): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: secret = mgr.charm.model.get_secret(label="mylabel") @@ -441,7 +441,7 @@ def test_update_metadata(mycharm): rotate=SecretRotate.DAILY, ) - secret_out = mgr.output.secrets[0] + secret_out = mgr.output.get_secret(label="babbuccia") assert secret_out.label == "babbuccia" assert secret_out.rotate == SecretRotate.DAILY assert secret_out.description == "blu" @@ -481,8 +481,8 @@ def post_event(charm: CharmBase): out = trigger( State( - relations=[Relation("foo", "remote")], - secrets=[ + relations={Relation("foo", "remote")}, + secrets={ Secret( id="foo", label="mylabel", @@ -492,7 +492,7 @@ def post_event(charm: CharmBase): 0: {"a": "b"}, }, ) - ], + }, ), "update_status", mycharm, @@ -514,9 +514,9 @@ def __init__(self, *args): state = State( leader=True, - relations=[ + relations={ Relation("bar", remote_app_name=relation_remote_app, id=relation_id) - ], + }, ) with ctx.manager(ctx.on.start(), state) as mgr: @@ -527,7 +527,7 @@ def __init__(self, *args): secret.grant(bar_relation) assert mgr.output.secrets - scenario_secret = mgr.output.secrets[0] + scenario_secret = mgr.output.get_secret(label="mylabel") assert relation_remote_app in scenario_secret.remote_grants[relation_id] with ctx.manager(ctx.on.start(), mgr.output) as mgr: @@ -535,7 +535,7 @@ def __init__(self, *args): secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) - scenario_secret = mgr.output.secrets[0] + scenario_secret = mgr.output.get_secret(label="mylabel") assert scenario_secret.remote_grants == {} with ctx.manager(ctx.on.start(), mgr.output) as mgr: @@ -543,7 +543,7 @@ def __init__(self, *args): secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() - assert not mgr.output.secrets[0].contents # secret wiped + assert not mgr.output.get_secret(label="mylabel").contents # secret wiped @pytest.mark.parametrize( diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 3f119909..aaa3246f 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -15,6 +15,7 @@ Model, Network, Relation, + Resource, State, ) from tests.helpers import jsonpatch_delta, sort_patch, trigger @@ -67,7 +68,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) - out_purged = replace(out, stored_state=state.stored_state) + out_purged = replace(out, stored_states=state.stored_states) assert jsonpatch_delta(state, out_purged) == [] @@ -106,7 +107,7 @@ def call(charm: CharmBase, e): assert out.workload_version == "" # ignore stored state in the delta - out_purged = replace(out, stored_state=state.stored_state) + out_purged = replace(out, stored_states=state.stored_states) assert jsonpatch_delta(out_purged, state) == sort_patch( [ {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, @@ -126,7 +127,7 @@ def pre_event(charm: CharmBase): assert container.can_connect() is connect trigger( - State(containers=[Container(name="foo", can_connect=connect)]), + State(containers={Container(name="foo", can_connect=connect)}), "start", mycharm, meta={ @@ -155,7 +156,7 @@ def pre_event(charm: CharmBase): assert not rel.data[unit] state = State( - relations=[ + relations={ Relation( endpoint="foo", interface="bar", @@ -165,7 +166,7 @@ def pre_event(charm: CharmBase): local_unit_data={"c": "d"}, remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, ) - ] + } ) trigger( state, @@ -215,7 +216,7 @@ def pre_event(charm: CharmBase): state = State( leader=True, planned_units=4, - relations=[relation], + relations={relation}, ) assert not mycharm.called @@ -231,25 +232,28 @@ def pre_event(charm: CharmBase): ) assert mycharm.called - assert asdict(out.relations[0]) == asdict( + assert asdict(out.get_relation(relation.id)) == asdict( replace( relation, local_app_data={"a": "b"}, local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, ) ) - - assert out.relations[0].local_app_data == {"a": "b"} - assert out.relations[0].local_unit_data == {"c": "d", **DEFAULT_JUJU_DATABAG} + assert out.get_relation(relation.id).local_app_data == {"a": "b"} + assert out.get_relation(relation.id).local_unit_data == { + "c": "d", + **DEFAULT_JUJU_DATABAG, + } @pytest.mark.parametrize( "klass,num_args", [ (State, (1,)), + (Resource, (1,)), (Address, (0, 2)), (BindAddress, (0, 2)), - (Network, (0, 2)), + (Network, (1, 2)), ], ) def test_positional_arguments(klass, num_args): @@ -285,13 +289,13 @@ def test_container_default_values(): def test_state_default_values(): state = State() assert state.config == {} - assert state.relations == [] - assert state.networks == {} - assert state.containers == [] - assert state.storage == [] - assert state.opened_ports == [] - assert state.secrets == [] - assert state.resources == {} + assert state.relations == frozenset() + assert state.networks == frozenset() + assert state.containers == frozenset() + assert state.storages == frozenset() + assert state.opened_ports == frozenset() + assert state.secrets == frozenset() + assert state.resources == frozenset() assert state.deferred == [] assert isinstance(state.model, Model) assert state.leader is False diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index b62288bb..3e6912fb 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -66,7 +66,7 @@ def test_storage_usage(storage_ctx): (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") with storage_ctx.manager( - storage_ctx.on.update_status(), State(storage=[storage]) + storage_ctx.on.update_status(), State(storages={storage}) ) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location @@ -85,9 +85,11 @@ def test_storage_usage(storage_ctx): def test_storage_attached_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storage=[storage])) + storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storages={storage})) def test_storage_detaching_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage_ctx.on.storage_detaching(storage), State(storage=[storage])) + storage_ctx.run( + storage_ctx.on.storage_detaching(storage), State(storages={storage}) + ) diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 38c38efd..94b9c301 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -32,25 +32,37 @@ def _on_event(self, event): def test_stored_state_default(mycharm): out = trigger(State(), "start", mycharm, meta=mycharm.META) - assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} + assert out.get_stored_state("_stored", owner_path="MyCharm").content == { + "foo": "bar", + "baz": {12: 142}, + } + assert out.get_stored_state("_stored2", owner_path="MyCharm").content == { + "foo": "bar", + "baz": {12: 142}, + } def test_stored_state_initialized(mycharm): out = trigger( State( - stored_state=[ + stored_states={ StoredState( owner_path="MyCharm", name="_stored", content={"foo": "FOOX"} ), - ] + } ), "start", mycharm, meta=mycharm.META, ) - # todo: ordering is messy? - assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} - assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} + assert out.get_stored_state("_stored", owner_path="MyCharm").content == { + "foo": "FOOX", + "baz": {12: 142}, + } + assert out.get_stored_state("_stored2", owner_path="MyCharm").content == { + "foo": "bar", + "baz": {12: 142}, + } def test_positional_arguments(): From f11809ad6db3415e1a97aed470bc3b99e5522f82 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 17 Jul 2024 15:19:12 +1200 Subject: [PATCH 19/35] feat!: add Scenario classes that match the ops status classes (#142) Adds classes that match the ops status classes: * UnknownStatus * ActiveStatus * WaitingStatus * MaintenanceStatus * BlockedStatus * ErrorStatus --- README.md | 25 +++--- scenario/__init__.py | 12 +++ scenario/context.py | 4 +- scenario/mocking.py | 4 +- scenario/state.py | 151 +++++++++++++++++++++++++++------ tests/test_e2e/test_actions.py | 2 +- tests/test_e2e/test_status.py | 38 ++++++--- 7 files changed, 183 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index d730e788..8983a3ec 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,12 @@ With that, we can write the simplest possible scenario test: def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(ctx.on.start(), scenario.State()) - assert out.unit_status == ops.UnknownStatus() + assert out.unit_status == scenario.UnknownStatus() ``` +Note that you should always compare the app and unit status using `==`, not `is`. You can compare +them to either the `scenario` objects, or the `ops` ones. + Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python @@ -110,7 +113,7 @@ class MyCharm(ops.CharmBase): def test_status_leader(leader): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(ctx.on.start(), scenario.State(leader=leader)) - assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled') + assert out.unit_status == scenario.ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can @@ -165,15 +168,15 @@ def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(ctx.on.start(), scenario.State(leader=False)) assert ctx.unit_status_history == [ - ops.UnknownStatus(), - ops.MaintenanceStatus('determining who the ruler is...'), - ops.WaitingStatus('checking this is right...'), + scenario.UnknownStatus(), + scenario.MaintenanceStatus('determining who the ruler is...'), + scenario.WaitingStatus('checking this is right...'), ] - assert out.unit_status == ops.ActiveStatus("I am ruled") + assert out.unit_status == scenario.ActiveStatus("I am ruled") # similarly you can check the app status history: assert ctx.app_status_history == [ - ops.UnknownStatus(), + scenario.UnknownStatus(), ... ] ``` @@ -198,9 +201,9 @@ class MyCharm(ops.CharmBase): # ... ctx = scenario.Context(MyCharm, meta={"name": "foo"}) -ctx.run(ctx.on.start(), scenario.State(unit_status=ops.ActiveStatus('foo'))) +ctx.run(ctx.on.start(), scenario.State(unit_status=scenario.ActiveStatus('foo'))) assert ctx.unit_status_history == [ - ops.ActiveStatus('foo'), # now the first status is active: 'foo'! + scenario.ActiveStatus('foo'), # now the first status is active: 'foo'! # ... ] ``` @@ -248,7 +251,7 @@ def test_emitted_full(): capture_deferred_events=True, capture_framework_events=True, ) - ctx.run(ctx.on.start(), scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) + ctx.run(ctx.on.start(), scenario.State(deferred=[ctx.on.update_status().deferred(MyCharm._foo)])) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ @@ -396,8 +399,6 @@ meta = { } ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) ctx.run(ctx.on.start(), state_in) # invalid: this unit's id cannot be the ID of a peer. - - ``` ### SubordinateRelation diff --git a/scenario/__init__.py b/scenario/__init__.py index fafc3631..aa70017c 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -4,14 +4,18 @@ from scenario.context import ActionOutput, Context from scenario.state import ( Action, + ActiveStatus, Address, BindAddress, + BlockedStatus, CloudCredential, CloudSpec, Container, DeferredEvent, + ErrorStatus, ExecOutput, ICMPPort, + MaintenanceStatus, Model, Mount, Network, @@ -27,6 +31,8 @@ SubordinateRelation, TCPPort, UDPPort, + UnknownStatus, + WaitingStatus, deferred, ) @@ -58,4 +64,10 @@ "StoredState", "State", "DeferredEvent", + "ErrorStatus", + "BlockedStatus", + "WaitingStatus", + "MaintenanceStatus", + "ActiveStatus", + "UnknownStatus", ] diff --git a/scenario/context.py b/scenario/context.py index c563814b..1930945a 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -557,9 +557,9 @@ def _get_storage_root(self, name: str, index: int) -> Path: def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: - self.app_status_history.append(cast("_EntityStatus", state.app_status)) + self.app_status_history.append(state.app_status) else: - self.unit_status_history.append(cast("_EntityStatus", state.unit_status)) + self.unit_status_history.append(state.unit_status) def manager(self, event: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. diff --git a/scenario/mocking.py b/scenario/mocking.py index a8dae087..94c56721 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -43,6 +43,7 @@ Network, PeerRelation, Storage, + _EntityStatus, _port_cls_by_protocol, _RawPortProtocolLiteral, _RawStatusLiteral, @@ -338,7 +339,8 @@ def status_set( is_app: bool = False, ): self._context._record_status(self._state, is_app) - self._state._update_status(status, message, is_app) + status_obj = _EntityStatus.from_status_name(status, message) + self._state._update_status(status_obj, is_app) def juju_log(self, level: str, message: str): self._context.juju_log.append(JujuLogLine(level, message)) diff --git a/scenario/state.py b/scenario/state.py index 12d7d301..d077c1f3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -16,6 +16,7 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Dict, Final, FrozenSet, @@ -32,6 +33,7 @@ ) from uuid import uuid4 +import ops import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents @@ -956,18 +958,17 @@ def get_notice( class _EntityStatus: """This class represents StatusBase and should not be interacted with directly.""" - # Why not use StatusBase directly? Because that's not json-serializable. + # Why not use StatusBase directly? Because that can't be used with + # dataclasses.asdict to then be JSON-serializable. name: _RawStatusLiteral message: str = "" + _entity_statuses: ClassVar[Dict[str, Type["_EntityStatus"]]] = {} + def __eq__(self, other): if isinstance(other, (StatusBase, _EntityStatus)): return (self.name, self.message) == (other.name, other.message) - logger.warning( - f"Comparing Status with {other} is not stable and will be forbidden soon." - f"Please compare with StatusBase directly.", - ) return super().__eq__(other) def __repr__(self): @@ -976,17 +977,89 @@ def __repr__(self): return f"{status_type_name}()" return f"{status_type_name}('{self.message}')" + @classmethod + def from_status_name( + cls, + name: _RawStatusLiteral, + message: str = "", + ) -> "_EntityStatus": + # Note that this won't work for UnknownStatus. + # All subclasses have a default 'name' attribute, but the type checker can't tell that. + return cls._entity_statuses[name](message=message) # type:ignore + + @classmethod + def from_ops(cls, obj: StatusBase) -> "_EntityStatus": + return cls.from_status_name(cast(_RawStatusLiteral, obj.name), obj.message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class UnknownStatus(_EntityStatus, ops.UnknownStatus): + __doc__ = ops.UnknownStatus.__doc__ + + name: Literal["unknown"] = "unknown" + + def __init__(self): + super().__init__(name=self.name) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class ErrorStatus(_EntityStatus, ops.ErrorStatus): + __doc__ = ops.ErrorStatus.__doc__ + + name: Literal["error"] = "error" + + def __init__(self, message: str = ""): + super().__init__(name="error", message=message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class ActiveStatus(_EntityStatus, ops.ActiveStatus): + __doc__ = ops.ActiveStatus.__doc__ + + name: Literal["active"] = "active" + + def __init__(self, message: str = ""): + super().__init__(name="active", message=message) -def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: - """Convert StatusBase to _EntityStatus.""" - statusbase_subclass = type(StatusBase.from_name(obj.name, obj.message)) - class _MyClass(_EntityStatus, statusbase_subclass): - # Custom type inheriting from a specific StatusBase subclass to support instance checks: - # isinstance(state.unit_status, ops.ActiveStatus) - pass +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class BlockedStatus(_EntityStatus, ops.BlockedStatus): + __doc__ = ops.BlockedStatus.__doc__ - return _MyClass(cast(_RawStatusLiteral, obj.name), obj.message) + name: Literal["blocked"] = "blocked" + + def __init__(self, message: str = ""): + super().__init__(name="blocked", message=message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class MaintenanceStatus(_EntityStatus, ops.MaintenanceStatus): + __doc__ = ops.MaintenanceStatus.__doc__ + + name: Literal["maintenance"] = "maintenance" + + def __init__(self, message: str = ""): + super().__init__(name="maintenance", message=message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class WaitingStatus(_EntityStatus, ops.WaitingStatus): + __doc__ = ops.WaitingStatus.__doc__ + + name: Literal["waiting"] = "waiting" + + def __init__(self, message: str = ""): + super().__init__(name="waiting", message=message) + + +_EntityStatus._entity_statuses.update( + unknown=UnknownStatus, + error=ErrorStatus, + active=ActiveStatus, + blocked=BlockedStatus, + maintenance=MaintenanceStatus, + waiting=WaitingStatus, +) @dataclasses.dataclass(frozen=True) @@ -1033,6 +1106,11 @@ def __post_init__(self): "please use TCPPort, UDPPort, or ICMPPort", ) + def __eq__(self, other: object) -> bool: + if isinstance(other, (_Port, ops.Port)): + return (self.protocol, self.port) == (other.protocol, other.port) + return False + @dataclasses.dataclass(frozen=True) class TCPPort(_Port): @@ -1112,6 +1190,11 @@ class Storage(_max_posargs(1)): index: int = dataclasses.field(default_factory=next_storage_index) # Every new Storage instance gets a new one, if there's trouble, override. + def __eq__(self, other: object) -> bool: + if isinstance(other, (Storage, ops.Storage)): + return (self.name, self.index) == (other.name, other.index) + return False + def get_filesystem(self, ctx: "Context") -> Path: """Simulated filesystem root in this context.""" return ctx._get_storage_root(self.name, self.index) @@ -1182,23 +1265,45 @@ class State(_max_posargs(0)): ) """Contents of a charm's stored state.""" - # the current statuses. Will be cast to _EntitiyStatus in __post_init__ - app_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + # the current statuses. + app_status: _EntityStatus = UnknownStatus() """Status of the application.""" - unit_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + unit_status: _EntityStatus = UnknownStatus() """Status of the unit.""" workload_version: str = "" """Workload version.""" def __post_init__(self): + # Let people pass in the ops classes, and convert them to the appropriate Scenario classes. for name in ["app_status", "unit_status"]: val = getattr(self, name) if isinstance(val, _EntityStatus): pass elif isinstance(val, StatusBase): - object.__setattr__(self, name, _status_to_entitystatus(val)) + object.__setattr__(self, name, _EntityStatus.from_ops(val)) else: raise TypeError(f"Invalid status.{name}: {val!r}") + normalised_ports = [ + _Port(protocol=port.protocol, port=port.port) + if isinstance(port, ops.Port) + else port + for port in self.opened_ports + ] + if self.opened_ports != normalised_ports: + object.__setattr__(self, "opened_ports", normalised_ports) + normalised_storage = [ + Storage(name=storage.name, index=storage.index) + if isinstance(storage, ops.Storage) + else storage + for storage in self.storages + ] + if self.storages != normalised_storage: + object.__setattr__(self, "storages", normalised_storage) + # ops.Container, ops.Model, ops.Relation, ops.Secret should not be instantiated by charmers. + # ops.Network does not have the relation name, so cannot be converted. + # ops.Resources does not contain the source of the resource, so cannot be converted. + # ops.StoredState is not convenient to initialise with data, so not useful here. + # It's convenient to pass a set, but we really want the attributes to be # frozen sets to increase the immutability of State objects. for name in [ @@ -1228,14 +1333,13 @@ def _update_workload_version(self, new_workload_version: str): def _update_status( self, - new_status: _RawStatusLiteral, - new_message: str = "", + new_status: _EntityStatus, is_app: bool = False, ): - """Update the current app/unit status and add the previous one to the history.""" + """Update the current app/unit status.""" name = "app_status" if is_app else "unit_status" # bypass frozen dataclass - object.__setattr__(self, name, _EntityStatus(new_status, new_message)) + object.__setattr__(self, name, new_status) def _update_opened_ports(self, new_ports: FrozenSet[_Port]): """Update the current opened ports.""" @@ -1262,10 +1366,7 @@ def with_leadership(self, leader: bool) -> "State": def with_unit_status(self, status: StatusBase) -> "State": return dataclasses.replace( self, - status=dataclasses.replace( - cast(_EntityStatus, self.unit_status), - unit=_status_to_entitystatus(status), - ), + unit_status=_EntityStatus.from_ops(status), ) def get_container(self, container: str, /) -> Container: diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 39a057e6..34c9cd94 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,7 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, State, _Event, next_action_id +from scenario.state import Action, State, next_action_id @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index 6b3bc0c5..f88f4b04 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -1,17 +1,18 @@ +import ops import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import ( + +from scenario import Context +from scenario.state import ( ActiveStatus, BlockedStatus, ErrorStatus, MaintenanceStatus, + State, UnknownStatus, WaitingStatus, ) - -from scenario import Context -from scenario.state import State, _status_to_entitystatus from tests.helpers import trigger @@ -52,9 +53,9 @@ def __init__(self, framework): def _on_update_status(self, _): for obj in (self.unit, self.app): - obj.status = ActiveStatus("1") - obj.status = BlockedStatus("2") - obj.status = WaitingStatus("3") + obj.status = ops.ActiveStatus("1") + obj.status = ops.BlockedStatus("2") + obj.status = ops.WaitingStatus("3") ctx = Context( StatusCharm, @@ -70,7 +71,7 @@ def _on_update_status(self, _): BlockedStatus("2"), ] - assert out.app_status == WaitingStatus("3") + assert out.app_status == ops.WaitingStatus("3") assert ctx.app_status_history == [ UnknownStatus(), ActiveStatus("1"), @@ -151,7 +152,20 @@ def _on_update_status(self, _): ), ) def test_status_comparison(status): - entitystatus = _status_to_entitystatus(status) - assert entitystatus == entitystatus == status - assert isinstance(entitystatus, type(status)) - assert repr(entitystatus) == repr(status) + if isinstance(status, UnknownStatus): + ops_status = ops.UnknownStatus() + else: + ops_status = getattr(ops, status.__class__.__name__)(status.message) + # A status can be compared to itself. + assert status == status + # A status can be compared to another instance of the scenario class. + if isinstance(status, UnknownStatus): + assert status == status.__class__() + else: + assert status == status.__class__(status.message) + # A status can be compared to an instance of the ops class. + assert status == ops_status + # isinstance also works for comparing to the ops classes. + assert isinstance(status, type(ops_status)) + # The repr of the scenario and ops classes should be identical. + assert repr(status) == repr(ops_status) From 962026022d771ccb2de73ecede40424e5cd9e9f2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Jul 2024 17:20:51 +1200 Subject: [PATCH 20/35] Make action events more like other events. (#161) --- .gitignore | 2 +- README.md | 11 +++++---- docs/custom_conf.py | 1 + scenario/__init__.py | 2 -- scenario/consistency_checker.py | 4 ++-- scenario/context.py | 40 ++++++++++++++++++------------- scenario/state.py | 15 +++++------- tests/test_consistency_checker.py | 26 ++++++++++---------- tests/test_context.py | 23 ++++++++---------- tests/test_context_on.py | 11 ++++----- tests/test_e2e/test_actions.py | 34 ++++++++------------------ tests/test_e2e/test_manager.py | 4 ++-- 12 files changed, 80 insertions(+), 93 deletions(-) diff --git a/.gitignore b/.gitignore index c20b5a45..a2f1492c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ venv/ build/ -docs/build/ +docs/_build/ *.charm .tox/ .coverage diff --git a/README.md b/README.md index 8983a3ec..87524339 100644 --- a/README.md +++ b/README.md @@ -957,7 +957,7 @@ def test_backup_action(): # If you didn't declare do_backup in the charm's metadata, # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. - out: scenario.ActionOutput = ctx.run_action("do_backup_action", scenario.State()) + out: scenario.ActionOutput = ctx.run_action(ctx.on.action("do_backup"), scenario.State()) # You can assert action results, logs, failure using the ActionOutput interface: assert out.logs == ['baz', 'qux'] @@ -973,17 +973,18 @@ def test_backup_action(): ## Parametrized Actions -If the action takes parameters, you'll need to instantiate an `Action`. +If the action takes parameters, you can pass those in the call. ```python def test_backup_action(): - # Define an action: - action = scenario.Action('do_backup', params={'a': 'b'}) ctx = scenario.Context(MyCharm) # If the parameters (or their type) don't match what is declared in the metadata, # the `ConsistencyChecker` will slap you on the other wrist. - out: scenario.ActionOutput = ctx.run_action(action, scenario.State()) + out: scenario.ActionOutput = ctx.run_action( + ctx.on.action("do_backup", params={'a': 'b'}), + scenario.State() + ) # ... ``` diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 37ad32ef..c7f2a943 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -308,6 +308,7 @@ def _compute_navigation_tree(context): # Please keep this list sorted alphabetically. ('py:class', 'AnyJson'), ('py:class', '_CharmSpec'), + ('py:class', '_Event'), ('py:class', 'scenario.state._DCBase'), ('py:class', 'scenario.state._EntityStatus'), ] diff --git a/scenario/__init__.py b/scenario/__init__.py index aa70017c..2f6471e7 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -3,7 +3,6 @@ # See LICENSE file for licensing details. from scenario.context import ActionOutput, Context from scenario.state import ( - Action, ActiveStatus, Address, BindAddress, @@ -37,7 +36,6 @@ ) __all__ = [ - "Action", "ActionOutput", "CloudCredential", "CloudSpec", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index be9b7fc1..0b54ee43 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -12,9 +12,9 @@ from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger from scenario.state import ( - Action, PeerRelation, SubordinateRelation, + _Action, _CharmSpec, normalize_name, ) @@ -274,7 +274,7 @@ def _check_storage_event( def _check_action_param_types( charm_spec: _CharmSpec, - action: Action, + action: _Action, errors: List[str], warnings: List[str], ): diff --git a/scenario/context.py b/scenario/context.py index 1930945a..af61eedf 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -12,11 +12,11 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( - Action, Container, MetadataNotFoundError, Secret, Storage, + _Action, _CharmSpec, _Event, _max_posargs, @@ -26,7 +26,7 @@ from ops.testing import CharmType from scenario.ops_main_mock import Ops - from scenario.state import AnyRelation, JujuLogLine, State, _EntityStatus + from scenario.state import AnyJson, AnyRelation, JujuLogLine, State, _EntityStatus PathLike = Union[str, Path] @@ -81,7 +81,7 @@ class _Manager: def __init__( self, ctx: "Context", - arg: Union[str, Action, _Event], + arg: Union[str, _Action, _Event], state_in: "State", ): self._ctx = ctx @@ -160,7 +160,7 @@ def run(self) -> "ActionOutput": @property def _runner(self): - return self._ctx._run_action # noqa + return self._ctx._run # noqa def _get_output(self): return self._ctx._finalize_action(self._ctx.output_state) # noqa @@ -312,6 +312,19 @@ def storage_detaching(storage: Storage): def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) + @staticmethod + def action( + name: str, + params: Optional[Dict[str, "AnyJson"]] = None, + id: Optional[str] = None, + ): + kwargs = {} + if params: + kwargs["params"] = params + if id: + kwargs["id"] = id + return _Event(f"{name}_action", action=_Action(name, **kwargs)) + class Context: """Represents a simulated charm's execution context. @@ -577,7 +590,7 @@ def manager(self, event: "_Event", state: "State"): """ return _EventManager(self, event, state) - def action_manager(self, action: "Action", state: "State"): + def action_manager(self, action: "_Action", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage: @@ -607,23 +620,23 @@ def run(self, event: "_Event", state: "State") -> "State": :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Event. """ - if isinstance(event, Action) or event.action: + if isinstance(event, _Action) or event.action: raise InvalidEventError("Use run_action() to run an action event.") with self._run_event(event=event, state=state) as ops: ops.emit() return self.output_state - def run_action(self, action: "Action", state: "State") -> ActionOutput: - """Trigger a charm execution with an Action and a State. + def run_action(self, event: "_Event", state: "State") -> ActionOutput: + """Trigger a charm execution with an action 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. - :arg action: the Action that the charm will execute. + :arg event: the action event that the charm will execute. :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the Action (event). + charm will invoke when handling the action event. """ - with self._run_action(action=action, state=state) as ops: + with self._run(event=event, state=state) as ops: ops.emit() return self._finalize_action(self.output_state) @@ -642,11 +655,6 @@ def _finalize_action(self, state_out: "State"): return ao - @contextmanager - def _run_action(self, action: "Action", state: "State"): - with self._run(event=action.event, state=state) as ops: - yield ops - @contextmanager def _run(self, event: "_Event", state: "State"): runtime = Runtime( diff --git a/scenario/state.py b/scenario/state.py index d077c1f3..6e9e9c82 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1670,7 +1670,7 @@ class Event: notice: Optional[Notice] = None """If this is a Pebble notice event, the notice it refers to.""" - action: Optional["Action"] = None + action: Optional["_Action"] = None """If this is an action event, the :class:`Action` it refers to.""" _owner_path: List[str] = dataclasses.field(default_factory=list) @@ -1827,15 +1827,17 @@ def next_action_id(*, update=True): @dataclasses.dataclass(frozen=True) -class Action(_max_posargs(1)): +class _Action(_max_posargs(1)): """A ``juju run`` command. Used to simulate ``juju run``, passing in any parameters. For example:: def test_backup_action(): - action = scenario.Action('do_backup', params={'filename': 'foo'}) ctx = scenario.Context(MyCharm) - out: scenario.ActionOutput = ctx.run_action(action, scenario.State()) + out: scenario.ActionOutput = ctx.run_action( + ctx.on.action('do_backup', params={'filename': 'foo'}), + scenario.State() + ) """ name: str @@ -1850,11 +1852,6 @@ def test_backup_action(): Every action invocation is automatically assigned a new one. Override in the rare cases where a specific ID is required.""" - @property - def event(self) -> _Event: - """Helper to generate an action event from this action.""" - return _Event(self.name + ACTION_EVENT_SUFFIX, action=self) - def deferred( event: Union[str, _Event], diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 82d9c76a..2e2efb9e 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -4,10 +4,10 @@ from ops.charm import CharmBase from scenario.consistency_checker import check_consistency +from scenario.context import Context from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, - Action, CloudCredential, CloudSpec, Container, @@ -22,6 +22,7 @@ Storage, StoredState, SubordinateRelation, + _Action, _CharmSpec, _Event, ) @@ -377,19 +378,19 @@ def test_relation_not_in_state(): def test_action_not_in_meta_inconsistent(): - action = Action("foo", params={"bar": "baz"}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec(MyCharm, meta={}, actions={}), ) def test_action_meta_type_inconsistent(): - action = Action("foo", params={"bar": "baz"}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "zabazaba"}}}} ), @@ -397,24 +398,24 @@ def test_action_meta_type_inconsistent(): assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec(MyCharm, meta={}, actions={"foo": {"params": {"bar": {}}}}), ) def test_action_name(): - action = Action("foo", params={"bar": "baz"}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_consistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}} ), ) assert_inconsistent( State(), - _Event("box_action", action=action), + _Event("box_action", action=ctx.on.action("foo", params={"bar": "baz"})), _CharmSpec(MyCharm, meta={}, actions={"foo": {}}), ) @@ -431,19 +432,18 @@ def test_action_name(): @pytest.mark.parametrize("ptype,good,bad", _ACTION_TYPE_CHECKS) def test_action_params_type(ptype, good, bad): - action = Action("foo", params={"bar": good}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_consistent( State(), - action.event, + ctx.on.action("foo", params={"bar": good}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}} ), ) if bad is not None: - action = Action("foo", params={"bar": bad}) assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": bad}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}} ), diff --git a/tests/test_context.py b/tests/test_context.py index aed14159..2ca8b93a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,8 +3,8 @@ import pytest from ops import CharmBase -from scenario import Action, ActionOutput, Context, State -from scenario.state import _Event, next_action_id +from scenario import ActionOutput, Context, State +from scenario.state import _Action, _Event, next_action_id class MyCharm(CharmBase): @@ -34,22 +34,19 @@ def test_run_action(): state = State() expected_id = next_action_id(update=False) - with patch.object(ctx, "_run_action") as p: - ctx._output_state = ( - "foo" # would normally be set within the _run_action call scope - ) - action = Action("do-foo") - output = ctx.run_action(action, state) + with patch.object(ctx, "_run") as p: + ctx._output_state = "foo" # would normally be set within the _run call scope + output = ctx.run_action(ctx.on.action("do-foo"), state) assert output.state == "foo" assert p.called - a = p.call_args.kwargs["action"] + e = p.call_args.kwargs["event"] s = p.call_args.kwargs["state"] - assert isinstance(a, Action) - assert a.event.name == "do_foo_action" + assert isinstance(e, _Event) + assert e.name == "do_foo_action" assert s is state - assert a.id == expected_id + assert e.action.id == expected_id @pytest.mark.parametrize("app_name", ("foo", "bar", "george")) @@ -76,6 +73,6 @@ def _on_act_action(self, _): pass ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) - out = ctx.run_action(Action("act"), State()) + out = ctx.run_action(ctx.on.action("act"), State()) assert out.results is None assert out.failure is None diff --git a/tests/test_context_on.py b/tests/test_context_on.py index 1c98b4ea..151bc303 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -173,8 +173,7 @@ def test_action_event_no_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run_action(ctx.on.action(action), state) - action = scenario.Action("act") - with ctx.action_manager(action, scenario.State()) as mgr: + with ctx.action_manager(ctx.on.action("act"), scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) @@ -184,18 +183,18 @@ def test_action_event_no_params(): def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - action = scenario.Action("act", params={"param": "hello"}) # These look like: # ctx.run_action(ctx.on.action(action=action), state) # So that any parameters can be included and the ID can be customised. - with ctx.action_manager(action, scenario.State()) as mgr: + call_event = ctx.on.action("act", params={"param": "hello"}) + with ctx.action_manager(call_event, scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) event = mgr.charm.observed[0] assert isinstance(event, ops.ActionEvent) - assert event.id == action.id - assert event.params["param"] == action.params["param"] + assert event.id == call_event.action.id + assert event.params["param"] == call_event.action.params["param"] def test_pebble_ready_event(): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 34c9cd94..b0668355 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,7 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, State, next_action_id +from scenario.state import State, _Action, next_action_id @pytest.fixture(scope="function") @@ -34,8 +34,7 @@ def test_action_event(mycharm, baz_value): "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} }, ) - action = Action("foo", params={"baz": baz_value, "bar": 10}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State()) evt = ctx.emitted_events[0] @@ -51,24 +50,15 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo"), State()) def test_cannot_run_action(mycharm): ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - action = Action("foo") with pytest.raises(InvalidEventError): - ctx.run(action, state=State()) - - -def test_cannot_run_action_event(mycharm): - ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - action = Action("foo") - with pytest.raises(InvalidEventError): - ctx.run(action.event, state=State()) + ctx.run(ctx.on.action("foo"), state=State()) @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) @@ -82,10 +72,9 @@ def handle_evt(charm: CharmBase, evt): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(action, State()) + out = ctx.run_action(ctx.on.action("foo"), State()) assert out.results == res_value assert out.success is True @@ -104,9 +93,8 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(action, State()) + out = ctx.run_action(ctx.on.action("foo"), State()) assert out.failure == "failed becozz" assert out.logs == ["log1", "log2"] @@ -133,9 +121,8 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo"), State()) @pytest.mark.skipif( @@ -151,20 +138,19 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo", id=uuid) ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo", id=uuid), State()) def test_positional_arguments(): with pytest.raises(TypeError): - Action("foo", {}) + _Action("foo", {}) def test_default_arguments(): expected_id = next_action_id(update=False) name = "foo" - action = Action(name) + action = _Action(name) assert action.name == name assert action.params == {} assert action.id == expected_id diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 66d39f82..3f99ffd0 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -4,7 +4,7 @@ from ops import ActiveStatus from ops.charm import CharmBase, CollectStatusEvent -from scenario import Action, Context, State +from scenario import Context, State from scenario.context import ActionOutput, AlreadyEmittedError, _EventManager @@ -73,7 +73,7 @@ def test_context_manager(mycharm): def test_context_action_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS) - with ctx.action_manager(Action("do-x"), State()) as manager: + with ctx.action_manager(ctx.on.action("do-x"), State()) as manager: ao = manager.run() assert isinstance(ao, ActionOutput) assert ctx.emitted_events[0].handle.kind == "do_x_action" From df27d5790eb1cf0862f8e2c5ad4b22a87ff4d9aa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Jul 2024 17:21:30 +1200 Subject: [PATCH 21/35] Use Iterable as the container component type, so that any iterable of hashable objects can be passed, but still convert to frozenset underneath. (#160) --- docs/custom_conf.py | 1 + scenario/state.py | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/custom_conf.py b/docs/custom_conf.py index c7f2a943..3e035474 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -311,4 +311,5 @@ def _compute_navigation_tree(context): ('py:class', '_Event'), ('py:class', 'scenario.state._DCBase'), ('py:class', 'scenario.state._EntityStatus'), + ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] diff --git a/scenario/state.py b/scenario/state.py index 6e9e9c82..535479fb 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -21,6 +21,7 @@ Final, FrozenSet, Generic, + Iterable, List, Literal, Optional, @@ -1221,9 +1222,9 @@ class State(_max_posargs(0)): default_factory=dict, ) """The present configuration of this charm.""" - relations: FrozenSet["AnyRelation"] = dataclasses.field(default_factory=frozenset) + relations: Iterable["AnyRelation"] = dataclasses.field(default_factory=frozenset) """All relations that currently exist for this charm.""" - networks: FrozenSet[Network] = dataclasses.field(default_factory=frozenset) + networks: Iterable[Network] = dataclasses.field(default_factory=frozenset) """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. @@ -1231,24 +1232,24 @@ class State(_max_posargs(0)): support it, but use at your own risk.] If a metadata-defined extra-binding is left empty, it will be defaulted. """ - containers: FrozenSet[Container] = dataclasses.field(default_factory=frozenset) + containers: Iterable[Container] = dataclasses.field(default_factory=frozenset) """All containers (whether they can connect or not) that this charm is aware of.""" - storages: FrozenSet[Storage] = dataclasses.field(default_factory=frozenset) + storages: Iterable[Storage] = dataclasses.field(default_factory=frozenset) """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: FrozenSet[_Port] = dataclasses.field(default_factory=frozenset) + opened_ports: Iterable[_Port] = dataclasses.field(default_factory=frozenset) """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: FrozenSet[Secret] = dataclasses.field(default_factory=frozenset) + 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: FrozenSet[Resource] = dataclasses.field(default_factory=frozenset) + 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. @@ -1260,7 +1261,7 @@ class State(_max_posargs(0)): # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) """Events that have been deferred on this charm by some previous execution.""" - stored_states: FrozenSet["StoredState"] = dataclasses.field( + stored_states: Iterable["StoredState"] = dataclasses.field( default_factory=frozenset, ) """Contents of a charm's stored state.""" @@ -1317,9 +1318,8 @@ def __post_init__(self): "stored_states", ]: val = getattr(self, name) - # We check for "not frozenset" rather than "is set" so that you can - # actually pass a tuple or list or really any iterable of hashable - # objects, and it will end up as a frozenset. + # It's ok to pass any iterable (of hashable objects), but you'll get + # a frozenset as the actual attribute. if not isinstance(val, frozenset): object.__setattr__(self, name, frozenset(val)) From d8c743b4bf124d1a0eb5024482d24ff85b8b37c7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Jul 2024 17:29:52 +1200 Subject: [PATCH 22/35] feat: add support for Pebble check events (#151) * Add support for Pebble checks. Also update the support for Pebble notices to be aligned with the 7.x approach. --- README.md | 20 ++++++- pyproject.toml | 2 +- scenario/__init__.py | 2 + scenario/consistency_checker.py | 11 ++++ scenario/context.py | 26 +++++++++ scenario/mocking.py | 5 +- scenario/runtime.py | 3 ++ scenario/state.py | 80 +++++++++++++++++----------- tests/test_consistency_checker.py | 41 +++++++++++++++ tests/test_e2e/test_deferred.py | 4 +- tests/test_e2e/test_pebble.py | 87 +++++++++++++++++++++++++++++-- tox.ini | 1 - 12 files changed, 245 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 87524339..ca0b0dc6 100644 --- a/README.md +++ b/README.md @@ -691,7 +691,25 @@ notices = [ scenario.Notice(key="example.com/c"), ] container = scenario.Container("my-container", notices=notices) -ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[container])) +state = scenario.State(containers={container}) +ctx.run(ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state) +``` + +### Pebble Checks + +A Pebble plan can contain checks, and when those checks exceed the configured +failure threshold, or start succeeding again after, Juju will emit a +pebble-check-failed or pebble-check-recovered event. In order to simulate these +events, you need to add a `CheckInfo` to the container. Note that the status of the +check doesn't have to match the event being generated: by the time that Juju +sends a pebble-check-failed event the check might have started passing again. + +```python +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) +check_info = scenario.CheckInfo("http-check", failures=7, status=ops.pebble.CheckStatus.DOWN) +container = scenario.Container("my-container", check_infos={check_info}) +state = scenario.State(containers={container}) +ctx.run(ctx.on.pebble_check_failed(info=check_info, container=container), state=state) ``` ## Storage diff --git a/pyproject.toml b/pyproject.toml index 5ce0b947..99f1be05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.12", + "ops>=2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/__init__.py b/scenario/__init__.py index 2f6471e7..bbd2c694 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -7,6 +7,7 @@ Address, BindAddress, BlockedStatus, + CheckInfo, CloudCredential, CloudSpec, Container, @@ -37,6 +38,7 @@ __all__ = [ "ActionOutput", + "CheckInfo", "CloudCredential", "CloudSpec", "Context", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 0b54ee43..e319a107 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -563,6 +563,9 @@ def check_containers_consistency( 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 + } errors = [] # it's fine if you have containers in meta that are not in state.containers (yet), but it's @@ -587,6 +590,14 @@ def check_containers_consistency( f"the event being processed concerns notice {event.notice!r}, but that " "notice is not in any of the containers present in the state.", ) + if ( + event.check_info + and (evt_container_name, event.check_info.name) not in all_checks + ): + errors.append( + f"the event being processed concerns check {event.check_info.name}, but that " + "check is not the {evt_container_name} container.", + ) # - a container in state.containers is not in meta.containers if diff := (set(state_containers).difference(set(meta_containers))): diff --git a/scenario/context.py b/scenario/context.py index af61eedf..5c02c674 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -12,8 +12,10 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( + CheckInfo, Container, MetadataNotFoundError, + Notice, Secret, Storage, _Action, @@ -312,6 +314,30 @@ def storage_detaching(storage: Storage): def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) + @staticmethod + def pebble_custom_notice(container: Container, notice: Notice): + return _Event( + f"{container.name}_pebble_custom_notice", + container=container, + notice=notice, + ) + + @staticmethod + def pebble_check_failed(container: Container, info: CheckInfo): + return _Event( + f"{container.name}_pebble_check_failed", + container=container, + check_info=info, + ) + + @staticmethod + def pebble_check_recovered(container: Container, info: CheckInfo): + return _Event( + f"{container.name}_pebble_check_recovered", + container=container, + check_info=info, + ) + @staticmethod def action( name: str, diff --git a/scenario/mocking.py b/scenario/mocking.py index 94c56721..3f53deb2 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -694,8 +694,9 @@ def __init__( self._root = container_root - # load any existing notices from the state + # load any existing notices and check information from the state self._notices: Dict[Tuple[str, str], pebble.Notice] = {} + self._check_infos: Dict[str, pebble.CheckInfo] = {} for container in state.containers: for notice in container.notices: if hasattr(notice.type, "value"): @@ -703,6 +704,8 @@ def __init__( else: notice_type = str(notice.type) self._notices[notice_type, notice.key] = notice._to_ops() + for check in container.check_infos: + self._check_infos[check.name] = check._to_ops() def get_plan(self) -> pebble.Plan: return self._container.plan diff --git a/scenario/runtime.py b/scenario/runtime.py index 97abe921..2f739f8f 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -271,6 +271,9 @@ def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): }, ) + if check_info := event.check_info: + env["JUJU_PEBBLE_CHECK_NAME"] = check_info.name + if storage := event.storage: env.update({"JUJU_STORAGE_ID": f"{storage.name}/{storage.index}"}) diff --git a/scenario/state.py b/scenario/state.py index 535479fb..e97a29cc 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -91,6 +91,8 @@ } PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" +PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed" +PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered" RELATION_EVENTS_SUFFIX = { "_relation_changed", "_relation_broken", @@ -770,18 +772,37 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class _BoundNotice(_max_posargs(0)): - notice: Notice - container: "Container" +class CheckInfo(_max_posargs(1)): + name: str + """Name of the check.""" - @property - def event(self): - """Sugar to generate a -pebble-custom-notice event for this notice.""" - suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX - return _Event( - path=normalize_name(self.container.name) + suffix, - container=self.container, - notice=self.notice, + level: Optional[pebble.CheckLevel] = None + """Level of the check.""" + + 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). + """ + + failures: int = 0 + """Number of failures since the check last succeeded.""" + + threshold: int = 3 + """Failure threshold. + + This is how many consecutive failures for the check to be considered “down”. + """ + + def _to_ops(self) -> pebble.CheckInfo: + return pebble.CheckInfo( + name=self.name, + level=self.level, + status=self.status, + failures=self.failures, + threshold=self.threshold, ) @@ -862,6 +883,8 @@ class Container(_max_posargs(1)): notices: List[Notice] = dataclasses.field(default_factory=list) + check_infos: FrozenSet[CheckInfo] = frozenset() + def __hash__(self) -> int: return hash(self.name) @@ -927,23 +950,6 @@ def get_filesystem(self, ctx: "Context") -> Path: """ return ctx._get_container_root(self.name) - def get_notice( - self, - key: str, - notice_type: pebble.NoticeType = pebble.NoticeType.CUSTOM, - ) -> _BoundNotice: - """Get a Pebble notice by key and type. - - Raises: - KeyError: if the notice is not found. - """ - for notice in self.notices: - if notice.key == key and notice.type == notice_type: - return _BoundNotice(notice=notice, container=self) - raise KeyError( - f"{self.name} does not have a notice with key {key} and type {notice_type}", - ) - _RawStatusLiteral = Literal[ "waiting", @@ -1631,6 +1637,10 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload if s.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): return PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload + if s.endswith(PEBBLE_CHECK_FAILED_EVENT_SUFFIX): + return PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload + if s.endswith(PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX): + return PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload if s in BUILTIN_EVENTS: return "", _EventType.builtin @@ -1670,6 +1680,9 @@ class Event: notice: Optional[Notice] = None """If this is a Pebble notice event, the notice it refers to.""" + check_info: Optional[CheckInfo] = None + """If this is a Pebble check event, the check info it provides.""" + action: Optional["_Action"] = None """If this is an action event, the :class:`Action` it refers to.""" @@ -1787,6 +1800,8 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: "notice_type": notice_type, }, ) + elif self.check_info: + snapshot_data["check_name"] = self.check_info.name elif self._is_relation_event: # this is a RelationEvent. @@ -1860,8 +1875,15 @@ def deferred( relation: Optional["Relation"] = None, container: Optional["Container"] = None, notice: Optional["Notice"] = None, + check_info: Optional["CheckInfo"] = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - event = _Event(event, relation=relation, container=container, notice=notice) + event = _Event( + event, + relation=relation, + container=container, + notice=notice, + check_info=check_info, + ) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 2e2efb9e..1b97b94b 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -8,6 +8,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, + CheckInfo, CloudCredential, CloudSpec, Container, @@ -85,6 +86,46 @@ def test_workload_event_without_container(): _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) + check = CheckInfo("http-check") + assert_consistent( + State(containers={Container("foo", check_infos={check})}), + _Event("foo-pebble-check-failed", container=Container("foo"), check_info=check), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_inconsistent( + State(containers={Container("foo")}), + _Event("foo-pebble-check-failed", container=Container("foo"), check_info=check), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_consistent( + State(containers={Container("foo", check_infos={check})}), + _Event( + "foo-pebble-check-recovered", container=Container("foo"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_inconsistent( + State(containers={Container("foo")}), + _Event( + "foo-pebble-check-recovered", container=Container("foo"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + # Ensure the check is in the correct container. + assert_inconsistent( + State(containers={Container("foo", check_infos={check}), Container("bar")}), + _Event( + "foo-pebble-check-recovered", container=Container("bar"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}, "bar": {}}}), + ) + assert_inconsistent( + State(containers={Container("foo", check_infos={check}), Container("bar")}), + _Event( + "bar-pebble-check-recovered", container=Container("bar"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}, "bar": {}}}), + ) def test_container_meta_mismatch(): diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index f988dcc5..2b21dd90 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -102,7 +102,9 @@ def test_deferred_workload_evt(mycharm): def test_deferred_notice_evt(mycharm): notice = Notice(key="example.com/bar") ctr = Container("foo", notices=[notice]) - evt1 = ctr.get_notice("example.com/bar").event.deferred(handler=mycharm._on_event) + evt1 = _Event("foo_pebble_custom_notice", notice=notice, container=ctr).deferred( + handler=mycharm._on_event + ) evt2 = deferred( event="foo_pebble_custom_notice", handler=mycharm._on_event, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 08acebc3..da40cf5d 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -10,7 +10,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import Container, ExecOutput, Mount, Notice, State +from scenario.state import CheckInfo, Container, ExecOutput, Mount, Notice, State from tests.helpers import jsonpatch_delta, trigger @@ -381,7 +381,9 @@ def test_pebble_custom_notice(charm_cls): state = State(containers=[container]) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(container.get_notice("example.com/baz").event, state) as mgr: + with ctx.manager( + ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state + ) as mgr: container = mgr.charm.unit.get_container("foo") assert container.get_notices() == [n._to_ops() for n in notices] @@ -437,4 +439,83 @@ def _on_custom_notice(self, event: PebbleCustomNoticeEvent): ) state = State(containers=[container]) ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) - ctx.run(container.get_notice(key).event, state) + ctx.run(ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state) + + +def test_pebble_check_failed(): + infos = [] + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_check_failed, self._on_check_failed) + + def _on_check_failed(self, event): + infos.append(event.info) + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + check = CheckInfo("http-check", failures=7, status=pebble.CheckStatus.DOWN) + container = Container("foo", check_infos={check}) + state = State(containers={container}) + ctx.run(ctx.on.pebble_check_failed(container, check), state=state) + assert len(infos) == 1 + assert infos[0].name == "http-check" + assert infos[0].status == pebble.CheckStatus.DOWN + assert infos[0].failures == 7 + + +def test_pebble_check_recovered(): + infos = [] + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe( + self.on.foo_pebble_check_recovered, self._on_check_recovered + ) + + def _on_check_recovered(self, event): + infos.append(event.info) + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + check = CheckInfo("http-check") + container = Container("foo", check_infos={check}) + state = State(containers={container}) + ctx.run(ctx.on.pebble_check_recovered(container, check), state=state) + assert len(infos) == 1 + assert infos[0].name == "http-check" + assert infos[0].status == pebble.CheckStatus.UP + assert infos[0].failures == 0 + + +def test_pebble_check_failed_two_containers(): + foo_infos = [] + bar_infos = [] + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe( + self.on.foo_pebble_check_failed, self._on_foo_check_failed + ) + framework.observe( + self.on.bar_pebble_check_failed, self._on_bar_check_failed + ) + + def _on_foo_check_failed(self, event): + foo_infos.append(event.info) + + def _on_bar_check_failed(self, event): + bar_infos.append(event.info) + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}, "bar": {}}}) + check = CheckInfo("http-check", failures=7, status=pebble.CheckStatus.DOWN) + foo_container = Container("foo", check_infos={check}) + bar_container = Container("bar", check_infos={check}) + state = State(containers={foo_container, bar_container}) + ctx.run(ctx.on.pebble_check_failed(foo_container, check), state=state) + assert len(foo_infos) == 1 + assert foo_infos[0].name == "http-check" + assert foo_infos[0].status == pebble.CheckStatus.DOWN + assert foo_infos[0].failures == 7 + assert len(bar_infos) == 0 diff --git a/tox.ini b/tox.ini index 317a3b14..9ecb3a32 100644 --- a/tox.ini +++ b/tox.ini @@ -91,7 +91,6 @@ allowlist_externals = cp deps = -e . - ops pytest pytest-markdown-docs commands = From d5b0de632708c855a8a9ffa45c59accd1aa4490b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 25 Jul 2024 21:06:07 +1200 Subject: [PATCH 23/35] feat!: move default network into Network() (#163) This moves the default Network into the `Network` initialisation, rather than as a separate classmethod that provides a `Network` object. This is more consistent with the rest of the Scenario objects, which try to have useful defaults where possible. For the most simple case: ```python # Previously network = Network.default() # Now network = Network("default") # The name is needed because of the `State` change elsewhere ``` To override elements of the default is a little bit more work, particularly if it's in the nested `Address` object, but it doesn't seem too bad: ```python # Previously network = Network.default(private_address="129.0.2.1") # Now network = Network("foo", [BindAddress([Address("129.0.2.1")])]) ``` --- README.md | 4 +-- scenario/mocking.py | 2 +- scenario/state.py | 48 +++++++++---------------------- tests/test_consistency_checker.py | 6 ++-- tests/test_e2e/test_network.py | 13 +++++++-- tests/test_e2e/test_state.py | 2 +- 6 files changed, 30 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index ca0b0dc6..3052ecf6 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ remote_unit_2_is_joining_event = ctx.on.relation_joined(relation, remote_unit=2) Simplifying a bit the Juju "spaces" model, each integration endpoint a charm defines in its metadata is associated with a network. Regardless of whether there is a living relation over that endpoint, that is. If your charm has a relation `"foo"` (defined in its metadata), then the charm will be able at runtime to do `self.model.get_binding("foo").network`. -The network you'll get by doing so is heavily defaulted (see `state.Network.default`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. +The network you'll get by doing so is heavily defaulted (see `state.Network`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. On top of the relation-provided network bindings, a charm can also define some `extra-bindings` in its metadata and access them at runtime. Note that this is a deprecated feature that should not be relied upon. For completeness, we support it in Scenario. @@ -496,7 +496,7 @@ If you want to, you can override any of these relation or extra-binding associat ```python state = scenario.State(networks={ - scenario.Network.default("foo", private_address='192.0.2.1') + scenario.Network("foo", [BindAddress([Address('192.0.2.1')])]) }) ``` diff --git a/scenario/mocking.py b/scenario/mocking.py index 3f53deb2..5879676f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -320,7 +320,7 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): try: network = self._state.get_network(binding_name) except KeyError: - network = Network.default("default") # The name is not used in the output. + network = Network("default") # The name is not used in the output. return network.hook_tool_output_fmt() # setter methods: these can mutate the state. diff --git a/scenario/state.py b/scenario/state.py index e97a29cc..9bf21939 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -340,11 +340,11 @@ def normalize_name(s: str): class Address(_max_posargs(1)): """An address in a Juju network space.""" - hostname: str - """A host name that maps to the address in :attr:`value`.""" value: str """The IP address in the space.""" - cidr: str + hostname: str = "" + """A host name that maps to the address in :attr:`value`.""" + cidr: str = "" """The CIDR of the address in :attr:`value`.""" @property @@ -379,11 +379,17 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network(_max_posargs(1)): +class Network(_max_posargs(2)): binding_name: str - bind_addresses: List[BindAddress] - ingress_addresses: List[str] - egress_subnets: List[str] + bind_addresses: List[BindAddress] = dataclasses.field( + default_factory=lambda: [BindAddress([Address("192.0.2.0")])], + ) + ingress_addresses: List[str] = dataclasses.field( + default_factory=lambda: ["192.0.2.0"], + ) + egress_subnets: List[str] = dataclasses.field( + default_factory=lambda: ["192.0.2.0/24"], + ) def __hash__(self) -> int: return hash(self.binding_name) @@ -396,34 +402,6 @@ def hook_tool_output_fmt(self): "ingress-addresses": self.ingress_addresses, } - @classmethod - def default( - cls, - binding_name: str, - private_address: str = "192.0.2.0", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("192.0.2.0/24",), - ingress_addresses=("192.0.2.0",), - ) -> "Network": - """Helper to create a minimal, heavily defaulted Network.""" - return cls( - binding_name=binding_name, - bind_addresses=[ - BindAddress( - interface_name=interface_name, - mac_address=mac_address, - addresses=[ - Address(hostname=hostname, value=private_address, cidr=cidr), - ], - ), - ], - egress_subnets=list(egress_subnets), - ingress_addresses=list(ingress_addresses), - ) - _next_relation_id_counter = 1 diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 1b97b94b..5695e5b9 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -634,7 +634,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( - State(networks={Network.default("foo")}), + State(networks={Network("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -643,7 +643,7 @@ def test_networks_consistency(): ) assert_inconsistent( - State(networks={Network.default("foo")}), + State(networks={Network("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -656,7 +656,7 @@ def test_networks_consistency(): ) assert_consistent( - State(networks={Network.default("foo")}), + State(networks={Network("foo")}), _Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 47302698..761e9c71 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -4,7 +4,14 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Network, Relation, State, SubordinateRelation +from scenario.state import ( + Address, + BindAddress, + Network, + Relation, + State, + SubordinateRelation, +) @pytest.fixture(scope="function") @@ -51,7 +58,7 @@ def test_ip_get(mycharm): id=1, ), ], - networks={Network.default("foo", private_address="4.4.4.4")}, + networks={Network("foo", [BindAddress([Address("4.4.4.4")])])}, ), ) as mgr: # we have a network for the relation @@ -113,7 +120,7 @@ def test_no_relation_error(mycharm): id=1, ), ], - networks={Network.default("bar")}, + networks={Network("bar")}, ), ) as mgr: with pytest.raises(RelationNotFoundError): diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index aaa3246f..325cda66 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -253,7 +253,7 @@ def pre_event(charm: CharmBase): (Resource, (1,)), (Address, (0, 2)), (BindAddress, (0, 2)), - (Network, (1, 2)), + (Network, (0, 3)), ], ) def test_positional_arguments(klass, num_args): From 8023e85c19c1d2f50c55c665a9db9bd93c2572d0 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 7 Aug 2024 15:37:04 +1200 Subject: [PATCH 24/35] feat!: simplify testing secret management (#168) Adjust testing secret management to be simpler - in particular, to avoid needing to manually manage revisions, which should be transparent to charms. Rather than `Secret`s having a dictionary of revision:content-dict, they have two content dictionaries, `tracked_content` (required) and `latest_content` (set to the same value as `tracked_content` if not provided). This matches what charms can see: only either the tracked revision or the latest revision. A new attribute, `removed_secret_revisions` is added to `Context` to track removal of secret revisions in the `secret-remove` and `secret-expired` hooks. Calling `secret-remove --revision` in those hooks must be done, so should be tested, but don't actually change the state that's visible to the charm (for `secret-remove` the whole point is that the secret revision is no longer visible to anyone, so it should be removed). Tests could mock the `secret_remove` method, but it seems cleaner to provide a mechanism, given that this should be part of any charm that uses secrets. Charms should only remove specific revisions in the `secret-remove` and `secret-expired` hooks, and only remove the revision that's provided, but it is possible to remove arbitrary revisions. Modelling this is complicated (the state of the Juju secret is a mess afterwards) and it is always a mistake, so rather than trying to make the model fit the bad code, an exception is raised instead. A warning is logged if a secret revision is created that is the same as the existing revision - in the latest Juju this is a no-op, but in earlier version it's a problem, and either way it's something that the charm should avoid if possible. --------- Co-authored-by: PietroPasotti --- README.md | 69 ++++-- scenario/context.py | 54 +---- scenario/mocking.py | 71 +++--- scenario/state.py | 51 +++-- tests/test_consistency_checker.py | 18 +- tests/test_context_on.py | 18 +- tests/test_e2e/test_secrets.py | 345 ++++++++++++++++-------------- 7 files changed, 327 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index 3052ecf6..982a21ff 100644 --- a/README.md +++ b/README.md @@ -705,9 +705,9 @@ check doesn't have to match the event being generated: by the time that Juju sends a pebble-check-failed event the check might have started passing again. ```python -ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my_container": {}}}) check_info = scenario.CheckInfo("http-check", failures=7, status=ops.pebble.CheckStatus.DOWN) -container = scenario.Container("my-container", check_infos={check_info}) +container = scenario.Container("my_container", check_infos={check_info}) state = scenario.State(containers={container}) ctx.run(ctx.on.pebble_check_failed(info=check_info, container=container), state=state) ``` @@ -804,22 +804,27 @@ Scenario has secrets. Here's how you use them. state = scenario.State( secrets={ scenario.Secret( - {0: {'key': 'public'}}, - id='foo', - ), - }, + tracked_content={'key': 'public'}, + latest_content={'key': 'public', 'cert': 'private'}, + ) + } ) ``` -The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping -from revision numbers (integers) to a `str:str` dict representing the payload of the revision. +The only mandatory arguments to Secret is the `tracked_content` dict: a `str:str` +mapping representing the content of the revision. If there is a newer revision +of the content than the one the unit that's handling the event is tracking, then +`latest_content` should also be provided - if it's not, then Scenario assumes +that `latest_content` is the `tracked_content`. If there are other revisions of +the content, simply don't include them: the unit has no way of knowing about +these. There are three cases: - the secret is owned by this app but not this unit, in which case this charm can only manage it if we are the leader - the secret is owned by this unit, in which case this charm can always manage it (leader or not) -- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it +- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it (this includes user secrets) -Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm', and that other charm has granted us view rights. +Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm' (or a user), and that other has granted us view rights. The presence of the secret in `State.secrets` entails that we have access to it, either as owners or as grantees. Therefore, if we're not owners, we must be grantees. Absence of a Secret from the known secrets list means we are not entitled to obtaining it in any way. The charm, indeed, shouldn't even know it exists. @@ -830,32 +835,52 @@ If this charm does not own the secret, but also it was not granted view rights b To specify a secret owned by this unit (or app): ```python +rel = scenario.Relation("web") state = scenario.State( secrets={ scenario.Secret( - {0: {'key': 'private'}}, - id='foo', + {'key': 'private'}, owner='unit', # or 'app' - remote_grants={0: {"remote"}} - # the secret owner has granted access to the "remote" app over some relation with ID 0 - ), - }, + # The secret owner has granted access to the "remote" app over some relation: + remote_grants={rel.id: {"remote"}} + ) + } ) ``` -To specify a secret owned by some other application and give this unit (or app) access to it: +To specify a secret owned by some other application, or a user secret, and give this unit (or app) access to it: ```python state = scenario.State( secrets={ scenario.Secret( - {0: {'key': 'public'}}, - id='foo', + {'key': 'public'}, # owner=None, which is the default - revision=0, # the revision that this unit (or app) is currently tracking - ), - }, + ) + } +) +``` + +When handling the `secret-expired` and `secret-remove` events, the charm must remove the specified revision of the secret. For `secret-remove`, the revision will no longer be in the `State`, because it's no longer in use (which is why the `secret-remove` event was triggered). To ensure that the charm is removing the secret, check the context for the history of secret removal: + +```python +class SecretCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_remove, self._on_secret_remove) + + def _on_secret_remove(self, event): + event.secret.remove_revision(event.revision) + + +ctx = scenario.Context(SecretCharm, meta={"name": "foo"}) +secret = scenario.Secret({"password": "xxxxxxxx"}, owner="app") +old_revision = 42 +state = ctx.run( + ctx.on.secret_remove(secret, revision=old_revision), + scenario.State(leader=True, secrets={secret}) ) +assert ctx.removed_secret_revisions == [old_revision] ``` ## StoredState diff --git a/scenario/context.py b/scenario/context.py index 5c02c674..359205f7 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -376,6 +376,7 @@ class Context: - :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 This allows you to write assertions not only on the output state, but also, to some @@ -441,48 +442,6 @@ def __init__( ): """Represents a simulated charm's execution context. - It is the main entry point to running a scenario test. - - 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. - - After you have instantiated Context, typically you will call one of `run()` or - `run_action()` 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: - Context is not cleaned up automatically between charm runs. - You can call `.clear()` to do some clean up, but we don't guarantee all state will be gone. - - Any side effects generated by executing the charm, that are not rightful part of the State, - are in fact stored in the Context: - - ``juju_log``: record of what the charm has sent to juju-log - - ``app_status_history``: record of the app statuses the charm has set - - ``unit_status_history``: record of the unit statuses the charm has set - - ``workload_version_history``: record of the workload versions the charm has set - - ``emitted_events``: record of the events (including custom ones) that the charm has - processed - - 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: - - >>> 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) - >>> # Act: prepare the state and emit an event - >>> state_out = c.run(c.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) - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. :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. @@ -497,16 +456,6 @@ def __init__( :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. - If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the - execution cwd, you need to use this. E.g.: - - >>> 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 not any((meta, actions, config)): @@ -551,6 +500,7 @@ def __init__( self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] self.workload_version_history: List[str] = [] + self.removed_secret_revisions: List[int] = [] self.emitted_events: List[EventBase] = [] self.requested_storages: Dict[str, int] = {} diff --git a/scenario/mocking.py b/scenario/mocking.py index 5879676f..94e706e9 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -2,7 +2,6 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. import datetime -import random import shutil from io import StringIO from pathlib import Path @@ -202,21 +201,16 @@ def _get_secret(self, id=None, label=None): return secrets[0] elif label: - secrets = [s for s in self._state.secrets if s.label == label] - if not secrets: - raise SecretNotFoundError(label) - return secrets[0] + try: + return self._state.get_secret(label=label) + except KeyError: + raise SecretNotFoundError(label) from None else: # if all goes well, this should never be reached. ops.model.Secret will check upon # instantiation that either an id or a label are set, and raise a TypeError if not. raise RuntimeError("need id or label.") - @staticmethod - def _generate_secret_id(): - id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)])) - return f"secret:{id}" - def _check_app_data_access(self, is_app: bool): if not isinstance(is_app, bool): raise TypeError("is_app parameter to relation_get must be a boolean") @@ -371,10 +365,8 @@ def secret_add( ) -> str: from scenario.state import Secret - secret_id = self._generate_secret_id() secret = Secret( - id=secret_id, - contents={0: content}, + content, label=label, description=description, expire=expire, @@ -384,7 +376,7 @@ def secret_add( secrets = set(self._state.secrets) secrets.add(secret) self._state._update_secrets(frozenset(secrets)) - return secret_id + return secret.id def _check_can_manage_secret( self, @@ -414,19 +406,19 @@ def secret_get( secret = self._get_secret(id, label) juju_version = self._context.juju_version if not (juju_version == "3.1.7" or juju_version >= "3.3.1"): - # in this medieval juju chapter, + # In this medieval Juju chapter, # secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 if secret.owner is not None: refresh = True - revision = secret.revision if peek or refresh: - revision = max(secret.contents.keys()) if refresh: - secret._set_revision(revision) + secret._track_latest_revision() + assert secret.latest_content is not None + return secret.latest_content - return secret.contents[revision] + return secret.tracked_content def secret_info_get( self, @@ -442,7 +434,7 @@ def secret_info_get( return SecretInfo( id=secret.id, label=secret.label, - revision=max(secret.contents), + revision=secret._latest_revision, expires=secret.expire, rotation=secret.rotate, rotates=None, # not implemented yet. @@ -461,6 +453,15 @@ def secret_set( secret = self._get_secret(id, label) self._check_can_manage_secret(secret) + if content == secret.latest_content: + # In Juju 3.6 and higher, this is a no-op, but it's good to warn + # charmers if they are doing this, because it's not generally good + # practice. + # https://bugs.launchpad.net/juju/+bug/2069238 + logger.warning( + f"secret {id} contents set to the existing value: new revision not needed", + ) + secret._update_metadata( content=content, label=label, @@ -499,10 +500,32 @@ def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) self._check_can_manage_secret(secret) - if revision: - del secret.contents[revision] - else: - secret.contents.clear() + # Removing all revisions means that the secret is removed, so is no + # longer in the state. + if revision is None: + secrets = set(self._state.secrets) + secrets.remove(secret) + self._state._update_secrets(frozenset(secrets)) + return + + # Juju does not prevent removing the tracked or latest revision, but it + # is always a mistake to do this. Rather than having the state model a + # secret where the tracked/latest revision cannot be retrieved but the + # secret still exists, we raise instead, so that charms know that there + # is a problem with their code. + if revision in (secret._tracked_revision, secret._latest_revision): + raise ValueError( + "Charms should not remove the latest revision of a secret. " + "Add a new revision with `set_content()` instead, and the previous " + "revision will be cleaned up by the secret owner when no longer in use.", + ) + + # For all other revisions, the content is not visible to the charm + # (this is as designed: the secret is being removed, so it should no + # longer be in use). That means that the state does not need to be + # modified - however, unit tests should be able to verify that the remove call was + # executed, so we track that in a history list in the context. + self._context.removed_secret_revisions.append(revision) def relation_remote_app_name( self, diff --git a/scenario/state.py b/scenario/state.py index 9bf21939..5de1545c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -7,7 +7,9 @@ import dataclasses import datetime import inspect +import random import re +import string from collections import namedtuple from enum import Enum from itertools import chain @@ -270,24 +272,26 @@ def _to_ops(self) -> CloudSpec_Ops: ) +def _generate_secret_id(): + # This doesn't account for collisions, but the odds are so low that it + # should not be possible in any realistic test run. + secret_id = "".join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(20) + ) + return f"secret:{secret_id}" + + @dataclasses.dataclass(frozen=True) class Secret(_max_posargs(1)): - # mapping from revision IDs to each revision's contents - contents: Dict[int, "RawSecretRevisionContents"] + tracked_content: "RawSecretRevisionContents" + latest_content: Optional["RawSecretRevisionContents"] = None - id: str - # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized - # secret id (`secret:` prefix) - # but user-created ones will not. Using post-init to patch it in feels bad, but requiring the user to - # add the prefix manually every time seems painful as well. + id: str = dataclasses.field(default_factory=_generate_secret_id) # 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 - # what revision is currently tracked by this charm. Only meaningful if owner=False - revision: int = 0 - # 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) @@ -297,13 +301,25 @@ class Secret(_max_posargs(1)): expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None + # what revision is currently tracked by this charm. Only meaningful if owner=False + _tracked_revision: int = 1 + + # what revision is the latest for this secret. + _latest_revision: int = 1 + def __hash__(self) -> int: return hash(self.id) - def _set_revision(self, revision: int): - """Set a new tracked revision.""" + def __post_init__(self): + if self.latest_content is None: + # bypass frozen dataclass + object.__setattr__(self, "latest_content", self.tracked_content) + + def _track_latest_revision(self): + """Set the current revision to the tracked revision.""" # bypass frozen dataclass - object.__setattr__(self, "revision", revision) + object.__setattr__(self, "_tracked_revision", self._latest_revision) + object.__setattr__(self, "tracked_content", self.latest_content) def _update_metadata( self, @@ -314,11 +330,12 @@ def _update_metadata( rotate: Optional[SecretRotate] = None, ): """Update the metadata.""" - revision = max(self.contents.keys()) - if content: - self.contents[revision + 1] = content - # bypass frozen dataclass + object.__setattr__(self, "_latest_revision", self._latest_revision + 1) + # TODO: if this is done twice in the same hook, then Juju ignores the + # first call, it doesn't continue to update like this does. + if content: + object.__setattr__(self, "latest_content", content) if label: object.__setattr__(self, "label", label) if description: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 5695e5b9..3db6f8e8 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -303,7 +303,7 @@ def test_config_secret_old_juju(juju_version): @pytest.mark.parametrize("bad_v", ("1.0", "0", "1.2", "2.35.42", "2.99.99", "2.99")) def test_secrets_jujuv_bad(bad_v): - secret = Secret("secret:foo", {0: {"a": "b"}}) + secret = Secret({"a": "b"}) assert_inconsistent( State(secrets={secret}), _Event("bar"), @@ -312,14 +312,14 @@ def test_secrets_jujuv_bad(bad_v): ) assert_inconsistent( State(secrets={secret}), - secret.changed_event, + _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), bad_v, ) assert_inconsistent( State(), - secret.changed_event, + _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), bad_v, ) @@ -328,7 +328,7 @@ def test_secrets_jujuv_bad(bad_v): @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) def test_secrets_jujuv_bad(good_v): assert_consistent( - State(secrets={Secret(id="secret:foo", contents={0: {"a": "b"}})}), + State(secrets={Secret({"a": "b"})}), _Event("bar"), _CharmSpec(MyCharm, {}), good_v, @@ -336,14 +336,14 @@ def test_secrets_jujuv_bad(good_v): def test_secret_not_in_state(): - secret = Secret(id="secret:foo", contents={"a": "b"}) + secret = Secret({"a": "b"}) assert_inconsistent( State(), _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), ) assert_consistent( - State(secrets=[secret]), + State(secrets={secret}), _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), ) @@ -723,11 +723,7 @@ def test_storedstate_consistency(): ) assert_inconsistent( State( - stored_states={ - StoredState( - owner_path=None, content={"secret": Secret(id="foo", contents={})} - ) - } + stored_states={StoredState(owner_path=None, content={"secret": Secret({})})} ), _Event("start"), _CharmSpec( diff --git a/tests/test_context_on.py b/tests/test_context_on.py index 151bc303..8ddbf4d4 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -81,10 +81,8 @@ def test_simple_events(event_name, event_kind): ) def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - secret = scenario.Secret( - id="secret:123", contents={0: {"password": "xxxx"}}, owner=owner - ) - state_in = scenario.State(secrets=[secret]) + secret = scenario.Secret({"password": "xxxx"}, owner=owner) + state_in = scenario.State(secrets={secret}) # These look like: # ctx.run(ctx.on.secret_changed(secret=secret), state) # The secret must always be passed because the same event name is used for @@ -114,11 +112,11 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): def test_revision_secret_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - id="secret:123", - contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + tracked_content={"password": "yyyy"}, + latest_content={"password": "xxxx"}, owner="app", ) - state_in = scenario.State(secrets=[secret]) + state_in = scenario.State(secrets={secret}) # These look like: # ctx.run(ctx.on.secret_expired(secret=secret, revision=revision), state) # The secret and revision must always be passed because the same event name @@ -137,11 +135,11 @@ def test_revision_secret_events(event_name, event_kind): def test_revision_secret_events_as_positional_arg(event_name): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - id="secret:123", - contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + tracked_content={"password": "yyyy"}, + latest_content={"password": "xxxx"}, owner=None, ) - state_in = scenario.State(secrets=[secret]) + state_in = scenario.State(secrets={secret}) with pytest.raises(TypeError): ctx.run(getattr(ctx.on, event_name)(secret, 42), state_in) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index a9a3697e..710efd61 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -1,5 +1,4 @@ import datetime -import warnings import pytest from ops import ( @@ -42,97 +41,91 @@ def test_get_secret_no_secret(mycharm): assert mgr.charm.model.get_secret(label="foo") -def test_get_secret(mycharm): +@pytest.mark.parametrize("owner", ("app", "unit")) +def test_get_secret(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret({"a": "b"}, owner=owner) with ctx.manager( - state=State(secrets={Secret(id="foo", contents={0: {"a": "b"}})}), + state=State(secrets={secret}), event=ctx.on.update_status(), ) as mgr: - assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" + assert mgr.charm.model.get_secret(id=secret.id).get_content()["a"] == "b" @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_get_refresh(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + tracked_content={"a": "b"}, + latest_content={"a": "c"}, + owner=owner, + ) with ctx.manager( ctx.on.update_status(), - State( - secrets={ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - owner=owner, - ) - } - ), + State(secrets={secret}), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" + assert ( + charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c" + ) @pytest.mark.parametrize("app", (True, False)) def test_get_secret_nonowner_peek_update(mycharm, app): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + tracked_content={"a": "b"}, + latest_content={"a": "c"}, + ) with ctx.manager( ctx.on.update_status(), State( leader=app, - secrets={ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - ), - }, + secrets={secret}, ), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" + assert charm.model.get_secret(id=secret.id).peek_content()["a"] == "c" + # Verify that the peek has not refreshed: + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" - assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" - assert charm.model.get_secret(id="foo").get_content()["a"] == "c" + assert ( + charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c" + ) + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "c" @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_owner_peek_update(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + tracked_content={"a": "b"}, + latest_content={"a": "c"}, + owner=owner, + ) with ctx.manager( ctx.on.update_status(), State( - secrets={ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - owner=owner, - ) - } + secrets={secret}, ), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" - assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" + assert charm.model.get_secret(id=secret.id).peek_content()["a"] == "c" + # Verify that the peek has not refreshed: + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" + assert ( + charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c" + ) @pytest.mark.parametrize("owner", ("app", "unit")) def test_secret_changed_owner_evt_fails(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) secret = Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, + tracked_content={"a": "b"}, + latest_content={"a": "c"}, owner=owner, ) with pytest.raises(ValueError): @@ -150,11 +143,8 @@ def test_secret_changed_owner_evt_fails(mycharm, owner): def test_consumer_events_failures(mycharm, evt_suffix, revision): ctx = Context(mycharm, meta={"name": "local"}) secret = Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, + tracked_content={"a": "b"}, + latest_content={"a": "c"}, ) kwargs = {"secret": secret} if revision is not None: @@ -178,15 +168,15 @@ def test_add(mycharm, app): assert mgr.output.secrets secret = mgr.output.get_secret(label="mylabel") - assert secret.contents[0] == {"foo": "bar"} + assert secret.latest_content == secret.tracked_content == {"foo": "bar"} assert secret.label == "mylabel" def test_set_legacy_behaviour(mycharm): # in juju < 3.1.7, secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 - rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6") + rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} with ctx.manager( ctx.on.update_status(), State(), @@ -211,26 +201,18 @@ def test_set_legacy_behaviour(mycharm): == rev2 ) - secret.set_content(rev3) state_out = mgr.run() - secret: ops_Secret = charm.model.get_secret(label="mylabel") - assert ( - secret.get_content() - == secret.peek_content() - == secret.get_content(refresh=True) - == rev3 - ) - assert state_out.get_secret(label="mylabel").contents == { - 0: rev1, - 1: rev2, - 2: rev3, - } + assert ( + state_out.get_secret(label="mylabel").tracked_content + == state_out.get_secret(label="mylabel").latest_content + == rev2 + ) def test_set(mycharm): - rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} ctx = Context(mycharm, meta={"name": "local"}) + rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} with ctx.manager( ctx.on.update_status(), State(), @@ -244,25 +226,25 @@ def test_set(mycharm): == rev1 ) + # TODO: if this is done in the same event hook, it's more complicated + # than this. Figure out what we should do here. + # Also the next test, for Juju 3.3 secret.set_content(rev2) assert secret.get_content() == rev1 assert secret.peek_content() == secret.get_content(refresh=True) == rev2 - secret.set_content(rev3) state_out = mgr.run() - assert secret.get_content() == rev2 - assert secret.peek_content() == secret.get_content(refresh=True) == rev3 - assert state_out.get_secret(label="mylabel").contents == { - 0: rev1, - 1: rev2, - 2: rev3, - } + assert ( + state_out.get_secret(label="mylabel").tracked_content + == state_out.get_secret(label="mylabel").latest_content + == rev2 + ) def test_set_juju33(mycharm): - rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1") + rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} with ctx.manager( ctx.on.update_status(), State(), @@ -276,44 +258,36 @@ def test_set_juju33(mycharm): assert secret.peek_content() == rev2 assert secret.get_content(refresh=True) == rev2 - secret.set_content(rev3) state_out = mgr.run() - assert secret.get_content() == rev2 - assert secret.peek_content() == rev3 - assert secret.get_content(refresh=True) == rev3 - assert state_out.get_secret(label="mylabel").contents == { - 0: rev1, - 1: rev2, - 2: rev3, - } + assert ( + state_out.get_secret(label="mylabel").tracked_content + == state_out.get_secret(label="mylabel").latest_content + == rev2 + ) @pytest.mark.parametrize("app", (True, False)) def test_meta(mycharm, app): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + {"a": "b"}, + owner="app" if app else "unit", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) with ctx.manager( ctx.on.update_status(), State( leader=True, - secrets={ - Secret( - owner="app" if app else "unit", - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: charm = mgr.charm assert charm.model.get_secret(label="mylabel") - secret = charm.model.get_secret(id="foo") + secret = charm.model.get_secret(id=secret.id) info = secret.get_info() assert secret.label is None @@ -332,32 +306,27 @@ def test_secret_permission_model(mycharm, leader, owner): ) ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + {"a": "b"}, + label="mylabel", + owner=owner, + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) + secret_id = secret.id with ctx.manager( ctx.on.update_status(), State( leader=leader, - secrets={ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - owner=owner, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: - secret = mgr.charm.model.get_secret(id="foo") + # can always view + secret: ops_Secret = mgr.charm.model.get_secret(id=secret_id) assert secret.get_content()["a"] == "b" assert secret.peek_content() assert secret.get_content(refresh=True) - # can always view - secret: ops_Secret = mgr.charm.model.get_secret(id="foo") - if expect_manage: assert secret.get_content() assert secret.peek_content() @@ -385,22 +354,18 @@ def test_grant(mycharm, app): ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} ) + secret = Secret( + {"a": "b"}, + owner="unit", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) with ctx.manager( ctx.on.update_status(), State( relations=[Relation("foo", "remote")], - secrets={ - Secret( - owner="unit", - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: charm = mgr.charm @@ -418,19 +383,15 @@ def test_update_metadata(mycharm): exp = datetime.datetime(2050, 12, 12) ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + {"a": "b"}, + owner="unit", + label="mylabel", + ) with ctx.manager( ctx.on.update_status(), State( - secrets={ - Secret( - owner="unit", - id="foo", - label="mylabel", - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: secret = mgr.charm.model.get_secret(label="mylabel") @@ -462,7 +423,7 @@ def _on_start(self, _): secret = self.unit.add_secret({"foo": "bar"}) secret.grant(self.model.relations["bar"][0]) - state = State(leader=leader, relations=[Relation("bar")]) + state = State(leader=leader, relations={Relation("bar")}) ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) @@ -470,29 +431,26 @@ def _on_start(self, _): def test_grant_nonowner(mycharm): - def post_event(charm: CharmBase): - secret = charm.model.get_secret(id="foo") + secret = Secret( + {"a": "b"}, + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) + secret_id = secret.id + def post_event(charm: CharmBase): + secret = charm.model.get_secret(id=secret_id) secret = charm.model.get_secret(label="mylabel") foo = charm.model.get_relation("foo") with pytest.raises(ModelError): secret.grant(relation=foo) - out = trigger( + trigger( State( relations={Relation("foo", "remote")}, - secrets={ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), "update_status", mycharm, @@ -503,8 +461,7 @@ def post_event(charm: CharmBase): def test_add_grant_revoke_remove(): class GrantingCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) + pass ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} @@ -543,7 +500,71 @@ def __init__(self, *args): secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() - assert not mgr.output.get_secret(label="mylabel").contents # secret wiped + with pytest.raises(KeyError): + mgr.output.get_secret(label="mylabel") + + +def test_secret_removed_event(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_remove, self._on_secret_remove) + + def _on_secret_remove(self, event): + event.secret.remove_revision(event.revision) + + ctx = Context(SecretCharm, meta={"name": "foo"}) + secret = Secret({"a": "b"}, owner="app") + old_revision = 42 + state = ctx.run( + ctx.on.secret_remove(secret, revision=old_revision), + State(leader=True, secrets={secret}), + ) + assert secret in state.secrets + assert ctx.removed_secret_revisions == [old_revision] + + +def test_secret_expired_event(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_expired, self._on_secret_expired) + + def _on_secret_expired(self, event): + event.secret.set_content({"password": "newpass"}) + event.secret.remove_revision(event.revision) + + ctx = Context(SecretCharm, meta={"name": "foo"}) + secret = Secret({"password": "oldpass"}, owner="app") + old_revision = 42 + state = ctx.run( + ctx.on.secret_expired(secret, revision=old_revision), + State(leader=True, secrets={secret}), + ) + assert state.get_secret(id=secret.id).latest_content == {"password": "newpass"} + assert ctx.removed_secret_revisions == [old_revision] + + +def test_remove_bad_revision(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_remove, self._on_secret_remove) + + def _on_secret_remove(self, event): + with pytest.raises(ValueError): + event.secret.remove_revision(event.revision) + + ctx = Context(SecretCharm, meta={"name": "foo"}) + secret = Secret({"a": "b"}, owner="app") + ctx.run( + ctx.on.secret_remove(secret, revision=secret._latest_revision), + State(leader=True, secrets={secret}), + ) + ctx.run( + ctx.on.secret_remove(secret, revision=secret._tracked_revision), + State(leader=True, secrets={secret}), + ) @pytest.mark.parametrize( @@ -576,17 +597,15 @@ def _on_event(self, event): def test_no_additional_positional_arguments(): with pytest.raises(TypeError): - Secret({}, None) + Secret({}, {}, None) def test_default_values(): contents = {"foo": "bar"} - id = "secret:1" - secret = Secret(contents, id=id) - assert secret.contents == contents - assert secret.id == id + secret = Secret(contents) + assert secret.latest_content == secret.tracked_content == contents + assert secret.id.startswith("secret:") assert secret.label is None - assert secret.revision == 0 assert secret.description is None assert secret.owner is None assert secret.rotate is None From f18efd378e34d78ed49f4e449a49f3172f7eaf8b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 8 Aug 2024 15:12:21 +1200 Subject: [PATCH 25/35] feat!: unify run() and run_action() and simplify context managers (#162) The `run_action()` method (both standalone and in the context manager) are removed. This means that all events, including action events, are emitted with the same `run()` call, and both return the output state. To get access to the results of actions, a new `action_output` attribute is added to the `Context`. This is a simplified representation of the Juju `operation` object (and the `task` objects in them), which are displayed with `juju run`, but also available via `juju show-operation`. The class has the same name as the Harness `ActionOutput` and will be consolidated into a single class when Scenario is added to ops. For example: ```python out = ctx.run_action(Action("backup", params={"output": "data.tar.gz"}), State()) assert out.results == {"size": 1234} assert out.logs = [..., ...] assert out.state... state = ctx.run(ctx.on.action("backup", params={"output": "data.tar.gz"}), State()) assert ctx.action_output.results == {"size": 1234} assert ctx.action_output.logs = [..., ...] assert state... ``` When the charm calls `event.fail()`, this raises an exception, in the same way that Harness does. For example: ```python out = ctx.run_action("bad-action", State()) assert out.failure == "couldn't do the thing" with pytest.raises(ActionFailed) as exc_info: ctx.run(ctx.on.action("bad-action"), State()) assert exc_info.value.message == "couldn't do the thing" ``` The advantage of this is that tests that the action is successful do not need to have `assert ctx.action_output.status != "failed"`, which is easy to miss. In addition, the `Context.manager` and `Context.action_manager` methods are replaced by the ability to use the `Context` object itself as a context manager. For example: ```python ctx = Context(MyCharm) with ctx(ctx.on.start(), State()) as event: event.charm.prepare() state = event.run() assert event.charm... ``` The same code is also used (with `ctx.on.action()`) for action events. Advantages: * Slightly shorter code (no ".manager" or ".action_manager") * Avoids naming complications with "manager", "action_manager" and the various alternatives proposed in #115. The `.output` property of the context manager is also removed. The context manager will still handle running the event if it's not done explicitly, but if that's the case then the output is not available. We want to encourage people to explicitly run the event, not rely on the automated behaviour - although I think it does make sense that it does run, rather than raise or end in a weird situation where the event never ran. This replaces #115 and #118, being a combination of ideas/discussion from both, plus incorporating the unification of run/run_action discussed here, and the "action fail raises" discussed elsewhere. Also, as drive-by changes while names are being made consistent: * `_Port` becomes `Port` * `_RelationBase` becomes (again) `RelationBase` --------- Co-authored-by: PietroPasotti --- README.md | 56 +++++--- docs/custom_conf.py | 1 + docs/index.rst | 1 - scenario/__init__.py | 8 +- scenario/context.py | 214 +++++++++--------------------- scenario/mocking.py | 9 +- scenario/runtime.py | 4 +- scenario/state.py | 45 ++++--- tests/helpers.py | 2 +- tests/test_charm_spec_autoload.py | 2 +- tests/test_context.py | 36 ++--- tests/test_context_on.py | 122 +++++++++-------- tests/test_e2e/test_actions.py | 99 ++++++++++---- tests/test_e2e/test_cloud_spec.py | 6 +- tests/test_e2e/test_manager.py | 24 ++-- tests/test_e2e/test_network.py | 6 +- tests/test_e2e/test_pebble.py | 10 +- tests/test_e2e/test_ports.py | 4 +- tests/test_e2e/test_relations.py | 6 +- tests/test_e2e/test_resource.py | 2 +- tests/test_e2e/test_secrets.py | 54 ++++---- tests/test_e2e/test_storage.py | 14 +- tests/test_e2e/test_vroot.py | 4 +- 23 files changed, 354 insertions(+), 375 deletions(-) diff --git a/README.md b/README.md index 982a21ff..0e2332ff 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,7 @@ If you want to, you can override any of these relation or extra-binding associat ```python state = scenario.State(networks={ - scenario.Network("foo", [BindAddress([Address('192.0.2.1')])]) + scenario.Network("foo", [scenario.BindAddress([scenario.Address('192.0.2.1')])]) }) ``` @@ -726,8 +726,8 @@ storage = scenario.Storage("foo") # Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager(ctx.on.update_status(), scenario.State(storages={storage})) as mgr: - foo = mgr.charm.model.storages["foo"][0] +with ctx(ctx.on.update_status(), scenario.State(storages={storage})) as manager: + foo = manager.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" assert path.exists() @@ -924,9 +924,9 @@ import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) resource = scenario.Resource(name='foo', path='/path/to/resource.tar') -with ctx.manager(ctx.on.start(), scenario.State(resources={resource})) as mgr: +with ctx(ctx.on.start(), scenario.State(resources={resource})) as manager: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. - path = mgr.charm.model.resources.fetch('foo') + path = manager.charm.model.resources.fetch('foo') assert path == pathlib.Path('/path/to/resource.tar') ``` @@ -988,7 +988,6 @@ class MyVMCharm(ops.CharmBase): An action is a special sort of event, even though `ops` handles them almost identically. In most cases, you'll want to inspect the 'results' of an action, or whether it has failed or logged something while executing. Many actions don't have a direct effect on the output state. -For this reason, the output state is less prominent in the return type of `Context.run_action`. How to test actions with scenario: @@ -1000,18 +999,32 @@ def test_backup_action(): # If you didn't declare do_backup in the charm's metadata, # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. - out: scenario.ActionOutput = ctx.run_action(ctx.on.action("do_backup"), scenario.State()) + state = ctx.run(ctx.on.action("do_backup"), scenario.State()) - # You can assert action results, logs, failure using the ActionOutput interface: - assert out.logs == ['baz', 'qux'] - - if out.success: - # If the action did not fail, we can read the results: - assert out.results == {'foo': 'bar'} + # You can assert on action results and logs using the context: + assert ctx.action_logs == ['baz', 'qux'] + assert ctx.action_results == {'foo': 'bar'} +``` + +## Failing Actions + +If the charm code calls `event.fail()` to indicate that the action has failed, +an `ActionFailed` exception will be raised. This avoids having to include +success checks in every test where the action is successful. + +```python +def test_backup_action_failed(): + ctx = scenario.Context(MyCharm) + + with pytest.raises(ActionFailed) as exc_info: + ctx.run(ctx.on.action("do_backup"), scenario.State()) + assert exc_info.value.message == "sorry, couldn't do the backup" + # The state is also available if that's required: + assert exc_info.value.state.get_container(...) - else: - # If the action fails, we can read a failure message: - assert out.failure == 'boo-hoo' + # You can still assert action results and logs that occured as well as the failure: + assert ctx.action_logs == ['baz', 'qux'] + assert ctx.action_results == {'foo': 'bar'} ``` ## Parametrized Actions @@ -1024,7 +1037,7 @@ def test_backup_action(): # If the parameters (or their type) don't match what is declared in the metadata, # the `ConsistencyChecker` will slap you on the other wrist. - out: scenario.ActionOutput = ctx.run_action( + state = ctx.run( ctx.on.action("do_backup", params={'a': 'b'}), scenario.State() ) @@ -1130,7 +1143,7 @@ Scenario is a black-box, state-transition testing framework. It makes it trivial B, but not to assert that, in the context of this charm execution, with this state, a certain charm-internal method was called and returned a given piece of data, or would return this and that _if_ it had been called. -Scenario offers a cheekily-named context manager for this use case specifically: +The Scenario `Context` object can be used as a context manager for this use case specifically: ```python notest from charms.bar.lib_name.v1.charm_lib import CharmLib @@ -1152,8 +1165,7 @@ class MyCharm(ops.CharmBase): def test_live_charm_introspection(mycharm): ctx = scenario.Context(mycharm, meta=mycharm.META) - # If you want to do this with actions, you can use `Context.action_manager` instead. - with ctx.manager("start", scenario.State()) as manager: + with ctx(ctx.on.start(), scenario.State()) as manager: # This is your charm instance, after ops has set it up: charm: MyCharm = manager.charm @@ -1174,8 +1186,8 @@ def test_live_charm_introspection(mycharm): assert state_out.unit_status == ... ``` -Note that you can't call `manager.run()` multiple times: the manager is a context that ensures that `ops.main` 'pauses' right -before emitting the event to hand you some introspection hooks, but for the rest this is a regular scenario test: you +Note that you can't call `manager.run()` multiple times: the object is a context that ensures that `ops.main` 'pauses' right +before emitting the event to hand you some introspection hooks, but for the rest this is a regular Scenario test: you can't emit multiple events in a single charm execution. # The virtual charm root diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 3e035474..10deb009 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -311,5 +311,6 @@ def _compute_navigation_tree(context): ('py:class', '_Event'), ('py:class', 'scenario.state._DCBase'), ('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 1a261b3d..4d1af4d9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,6 @@ scenario.Context .. automodule:: scenario.context - scenario.consistency_checker ============================ diff --git a/scenario/__init__.py b/scenario/__init__.py index bbd2c694..24c1cac0 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from scenario.context import ActionOutput, Context +from scenario.context import Context, Manager from scenario.state import ( + ActionFailed, ActiveStatus, Address, BindAddress, @@ -21,6 +22,7 @@ Network, Notice, PeerRelation, + Port, Relation, Resource, Secret, @@ -37,7 +39,7 @@ ) __all__ = [ - "ActionOutput", + "ActionFailed", "CheckInfo", "CloudCredential", "CloudSpec", @@ -56,6 +58,7 @@ "Address", "BindAddress", "Network", + "Port", "ICMPPort", "TCPPort", "UDPPort", @@ -70,4 +73,5 @@ "MaintenanceStatus", "ActiveStatus", "UnknownStatus", + "Manager", ] diff --git a/scenario/context.py b/scenario/context.py index 359205f7..7998ceb4 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -import dataclasses import tempfile from contextlib import contextmanager from pathlib import Path @@ -12,6 +11,7 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( + ActionFailed, CheckInfo, Container, MetadataNotFoundError, @@ -21,7 +21,6 @@ _Action, _CharmSpec, _Event, - _max_posargs, ) if TYPE_CHECKING: # pragma: no cover @@ -37,36 +36,12 @@ DEFAULT_JUJU_VERSION = "3.4" -@dataclasses.dataclass(frozen=True) -class ActionOutput(_max_posargs(0)): - """Wraps the results of running an action event with ``run_action``.""" - - state: "State" - """The charm state after the action has been handled. - - In most cases, actions are not expected to be affecting it.""" - logs: List[str] - """Any logs associated with the action output, set by the charm with - :meth:`ops.ActionEvent.log`.""" - results: Optional[Dict[str, Any]] - """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`.""" - failure: Optional[str] = None - """None if the action was successful, otherwise the message the charm set with - :meth:`ops.ActionEvent.fail`.""" - - @property - def success(self) -> bool: - """True if this action was a success, False otherwise.""" - return self.failure is None - - class InvalidEventError(RuntimeError): - """raised when something is wrong with the event passed to Context.run_*""" + """raised when something is wrong with the event passed to Context.run""" class InvalidActionError(InvalidEventError): - """raised when something is wrong with the action passed to Context.run_action""" + """raised when something is wrong with an action passed to Context.run""" class ContextSetupError(RuntimeError): @@ -77,13 +52,22 @@ class AlreadyEmittedError(RuntimeError): """Raised when ``run()`` is called more than once.""" -class _Manager: - """Context manager to offer test code some runtime charm object introspection.""" +class Manager: + """Context manager to offer test code some runtime charm object introspection. + + This class should not be instantiated directly: use a :class:`Context` + in a ``with`` statement instead, for example:: + + ctx = Context(MyCharm) + with ctx(ctx.on.start(), State()) as manager: + manager.charm.setup() + manager.run() + """ def __init__( self, ctx: "Context", - arg: Union[str, _Action, _Event], + arg: _Event, state_in: "State", ): self._ctx = ctx @@ -91,25 +75,21 @@ def __init__( self._state_in = state_in self._emitted: bool = False - self._run = None self.ops: Optional["Ops"] = None - self.output: Optional[Union["State", ActionOutput]] = None + self.output: Optional["State"] = None @property def charm(self) -> CharmBase: if not self.ops: raise RuntimeError( - "you should __enter__ this contextmanager before accessing this", + "you should __enter__ this context manager before accessing this", ) return cast(CharmBase, self.ops.charm) @property def _runner(self): - raise NotImplementedError("override in subclass") - - def _get_output(self): - raise NotImplementedError("override in subclass") + return self._ctx._run # noqa def __enter__(self): self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in) @@ -117,57 +97,29 @@ def __enter__(self): self.ops = ops return self - def run(self) -> Union[ActionOutput, "State"]: + def run(self) -> "State": """Emit the event and proceed with charm execution. This can only be done once. """ if self._emitted: - raise AlreadyEmittedError("Can only context.manager.run() once.") + raise AlreadyEmittedError("Can only run once.") self._emitted = True # wrap up Runtime.exec() so that we can gather the output state self._wrapped_ctx.__exit__(None, None, None) - self.output = out = self._get_output() - return out + assert self._ctx._output_state is not None + return self._ctx._output_state def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 if not self._emitted: - logger.debug("manager not invoked. Doing so implicitly...") + logger.debug( + "user didn't emit the event within the context manager scope. Doing so implicitly upon exit...", + ) self.run() -class _EventManager(_Manager): - if TYPE_CHECKING: # pragma: no cover - output: State # pyright: ignore[reportIncompatibleVariableOverride] - - def run(self) -> "State": - return cast("State", super().run()) - - @property - def _runner(self): - return self._ctx._run_event # noqa - - def _get_output(self): - return self._ctx._output_state # noqa - - -class _ActionManager(_Manager): - if TYPE_CHECKING: # pragma: no cover - output: ActionOutput # pyright: ignore[reportIncompatibleVariableOverride] - - def run(self) -> "ActionOutput": - return cast("ActionOutput", super().run()) - - @property - def _runner(self): - return self._ctx._run # noqa - - def _get_output(self): - return self._ctx._finalize_action(self._ctx.output_state) # noqa - - class _CharmEvents: """Events generated by Juju pertaining to application lifecycle. @@ -360,14 +312,12 @@ class Context: 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. - After you have instantiated ``Context``, typically you will call one of ``run()`` or - ``run_action()`` 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``. + 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 + assertions you like on the ``Context`` attributes, then discard the ``Context``. Each ``Context`` instance is in principle designed to be single-use: ``Context`` is not cleaned up automatically between charm runs. - You can call ``.clear()`` to do some clean up, but we don't guarantee all state will be gone. Any side effects generated by executing the charm, that are not rightful part of the ``State``, are in fact stored in the ``Context``: @@ -378,6 +328,10 @@ class Context: - :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` 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. @@ -410,19 +364,16 @@ def test_foo(): (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') scenario.Context(... charm_root=virtual_root).run(...) - Args: - charm_type: the CharmBase subclass to call :meth:`ops.main` on. - 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. - actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict). - If none is provided, we will search for a ``actions.yaml`` file in the charm root. - config: charm config to use. Needs to be a valid config.yaml format (as a dict). - If none is provided, we will search for a ``config.yaml`` file in the charm root. - juju_version: Juju agent version to simulate. - app_name: App name that this charm is deployed as. Defaults to the charm name as - defined in its metadata - unit_id: Unit ID that this charm is deployed as. Defaults to 0. - charm_root: virtual charm root the charm will be executed with. + 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) + with ctx(ctx.on.start(), State()) as manager: + manager.charm._some_private_setup() + manager.run() """ def __init__( @@ -507,11 +458,10 @@ def __init__( # set by Runtime.exec() in self._run() self._output_state: Optional["State"] = None - # ephemeral side effects from running an action - - self._action_logs: List[str] = [] - self._action_results: Optional[Dict[str, str]] = None - self._action_failure: Optional[str] = None + # operations (and embedded tasks) from running actions + self.action_logs: List[str] = [] + self.action_results: Optional[Dict[str, Any]] = None + self._action_failure_message: Optional[str] = None self.on = _CharmEvents() @@ -550,13 +500,14 @@ def _record_status(self, state: "State", is_app: bool): else: self.unit_status_history.append(state.unit_status) - def manager(self, event: "_Event", state: "State"): + def __call__(self, event: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage:: - with Context().manager("start", State()) as manager: - assert manager.charm._some_private_attribute == "foo" # noqa + ctx = Context(MyCharm) + with ctx(ctx.on.start(), State()) as manager: + manager.charm._some_private_setup() manager.run() # this will fire the event assert manager.charm._some_private_attribute == "bar" # noqa @@ -564,27 +515,7 @@ def manager(self, event: "_Event", state: "State"): event: the :class:`Event` that the charm will respond to. state: the :class:`State` instance to use when handling the Event. """ - return _EventManager(self, event, state) - - def action_manager(self, action: "_Action", state: "State"): - """Context manager to introspect live charm object before and after the event is emitted. - - Usage: - >>> with Context().action_manager(Action("foo"), State()) as manager: - >>> assert manager.charm._some_private_attribute == "foo" # noqa - >>> manager.run() # this will fire the event - >>> assert manager.charm._some_private_attribute == "bar" # noqa - - :arg action: the Action that the charm will execute. - :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the Action (event). - """ - return _ActionManager(self, action, state) - - @contextmanager - def _run_event(self, event: "_Event", state: "State"): - with self._run(event=event, state=state) as ops: - yield ops + return Manager(self, event, state) def run(self, event: "_Event", state: "State") -> "State": """Trigger a charm execution with an Event and a State. @@ -596,40 +527,19 @@ def run(self, event: "_Event", state: "State") -> "State": :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Event. """ - if isinstance(event, _Action) or event.action: - raise InvalidEventError("Use run_action() to run an action event.") - with self._run_event(event=event, state=state) as ops: - ops.emit() - return self.output_state - - def run_action(self, event: "_Event", state: "State") -> ActionOutput: - """Trigger a charm execution with an action 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. - - :arg event: the action event that the charm will execute. - :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the action event. - """ + if event.action: + # Reset the logs, failure status, and results, in case the context + # is reused. + self.action_logs.clear() + if self.action_results is not None: + self.action_results.clear() + self._action_failure_message = None with self._run(event=event, state=state) as ops: ops.emit() - return self._finalize_action(self.output_state) - - def _finalize_action(self, state_out: "State"): - ao = ActionOutput( - state=state_out, - logs=self._action_logs, - results=self._action_results, - failure=self._action_failure, - ) - - # reset all action-related state - self._action_logs = [] - self._action_results = None - self._action_failure = None - - return ao + if event.action: + if self._action_failure_message is not None: + raise ActionFailed(self._action_failure_message, self.output_state) + return self.output_state @contextmanager def _run(self, event: "_Event", state: "State"): diff --git a/scenario/mocking.py b/scenario/mocking.py index 94e706e9..04f5a873 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -554,21 +554,24 @@ def action_set(self, results: Dict[str, Any]): _format_action_result_dict(results) # but then we will store it in its unformatted, # original form for testing ease - self._context._action_results = results + if self._context.action_results: + self._context.action_results.update(results) + else: + self._context.action_results = results def action_fail(self, message: str = ""): if not self._event.action: raise ActionMissingFromContextError( "not in the context of an action event: cannot action-fail", ) - self._context._action_failure = message + self._context._action_failure_message = message def action_log(self, message: str): if not self._event.action: raise ActionMissingFromContextError( "not in the context of an action event: cannot action-log", ) - self._context._action_logs.append(message) + self._context.action_logs.append(message) def action_get(self): action = self._event.action diff --git a/scenario/runtime.py b/scenario/runtime.py index 2f739f8f..e853c682 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -20,7 +20,7 @@ from scenario.capture_events import capture_events from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.state import DeferredEvent, PeerRelation, StoredState +from scenario.state import ActionFailed, DeferredEvent, PeerRelation, StoredState if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -466,7 +466,7 @@ def exec( # if the caller did not manually emit or commit: do that. ops.finalize() - except NoObserverError: + except (NoObserverError, ActionFailed): raise # propagate along except Exception as e: raise UncaughtCharmError( diff --git a/scenario/state.py b/scenario/state.py index 5de1545c..d1eb108a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -133,6 +133,14 @@ class MetadataNotFoundError(RuntimeError): """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()`.""" + + def __init__(self, message: str, state: "State"): + self.message = message + self.state = state + + # This can be replaced with the KW_ONLY dataclasses functionality in Python 3.10+. def _max_posargs(n: int): class _MaxPositionalArgs: @@ -432,7 +440,7 @@ def next_relation_id(*, update=True): @dataclasses.dataclass(frozen=True) -class _RelationBase(_max_posargs(2)): +class RelationBase(_max_posargs(2)): endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -471,9 +479,9 @@ def _get_databag_for_remote( raise NotImplementedError() def __post_init__(self): - if type(self) is _RelationBase: + if type(self) is RelationBase: raise RuntimeError( - "_RelationBase cannot be instantiated directly; " + "RelationBase cannot be instantiated directly; " "please use Relation, PeerRelation, or SubordinateRelation", ) @@ -548,7 +556,7 @@ def _databags(self): @dataclasses.dataclass(frozen=True) -class SubordinateRelation(_RelationBase): +class SubordinateRelation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), @@ -1092,8 +1100,12 @@ def __hash__(self) -> int: @dataclasses.dataclass(frozen=True) -class _Port(_max_posargs(1)): - """Represents a port on the charm host.""" +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: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" @@ -1102,20 +1114,20 @@ class _Port(_max_posargs(1)): """The protocol that data transferred over the port will use.""" def __post_init__(self): - if type(self) is _Port: + if type(self) is Port: raise RuntimeError( - "_Port cannot be instantiated directly; " + "Port cannot be instantiated directly; " "please use TCPPort, UDPPort, or ICMPPort", ) def __eq__(self, other: object) -> bool: - if isinstance(other, (_Port, ops.Port)): + if isinstance(other, (Port, ops.Port)): return (self.protocol, self.port) == (other.protocol, other.port) return False @dataclasses.dataclass(frozen=True) -class TCPPort(_Port): +class TCPPort(Port): """Represents a TCP port on the charm host.""" port: int @@ -1131,7 +1143,7 @@ def __post_init__(self): @dataclasses.dataclass(frozen=True) -class UDPPort(_Port): +class UDPPort(Port): """Represents a UDP port on the charm host.""" port: int @@ -1147,7 +1159,7 @@ def __post_init__(self): @dataclasses.dataclass(frozen=True) -class ICMPPort(_Port): +class ICMPPort(Port): """Represents an ICMP port on the charm host.""" protocol: _RawPortProtocolLiteral = "icmp" @@ -1240,7 +1252,7 @@ class State(_max_posargs(0)): 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) + opened_ports: Iterable[Port] = dataclasses.field(default_factory=frozenset) """Ports opened by juju on this charm.""" leader: bool = False """Whether this charm has leadership.""" @@ -1286,7 +1298,7 @@ def __post_init__(self): else: raise TypeError(f"Invalid status.{name}: {val!r}") normalised_ports = [ - _Port(protocol=port.protocol, port=port.port) + Port(protocol=port.protocol, port=port.port) if isinstance(port, ops.Port) else port for port in self.opened_ports @@ -1342,7 +1354,7 @@ def _update_status( # bypass frozen dataclass object.__setattr__(self, name, new_status) - def _update_opened_ports(self, new_ports: FrozenSet[_Port]): + def _update_opened_ports(self, new_ports: FrozenSet[Port]): """Update the current opened ports.""" # bypass frozen dataclass object.__setattr__(self, "opened_ports", new_ports) @@ -1844,10 +1856,11 @@ class _Action(_max_posargs(1)): def test_backup_action(): ctx = scenario.Context(MyCharm) - out: scenario.ActionOutput = ctx.run_action( + state = ctx.run( ctx.on.action('do_backup', params={'filename': 'foo'}), scenario.State() ) + assert ctx.action_results == ... """ name: str diff --git a/tests/helpers.py b/tests/helpers.py index c8060d1c..82161c79 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -58,7 +58,7 @@ def trigger( event = getattr(ctx.on, event)(tuple(state.containers)[0]) else: event = getattr(ctx.on, event)() - with ctx.manager(event, state=state) as mgr: + with ctx(event, state=state) as mgr: if pre_event: pre_event(mgr.charm) state_out = mgr.run() diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index fb738f87..57b93a31 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -162,6 +162,6 @@ def test_config_defaults(tmp_path, legacy): ) as charm: # this would fail if there were no 'cuddles' relation defined in meta ctx = Context(charm) - with ctx.manager(ctx.on.start(), State()) as mgr: + with ctx(ctx.on.start(), State()) as mgr: mgr.run() assert mgr.charm.config["foo"] is True diff --git a/tests/test_context.py b/tests/test_context.py index 2ca8b93a..361b4543 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,8 +3,8 @@ import pytest from ops import CharmBase -from scenario import ActionOutput, Context, State -from scenario.state import _Action, _Event, next_action_id +from scenario import Context, State +from scenario.state import _Event, next_action_id class MyCharm(CharmBase): @@ -36,8 +36,8 @@ def test_run_action(): with patch.object(ctx, "_run") as p: ctx._output_state = "foo" # would normally be set within the _run call scope - output = ctx.run_action(ctx.on.action("do-foo"), state) - assert output.state == "foo" + output = ctx.run(ctx.on.action("do-foo"), state) + assert output == "foo" assert p.called e = p.call_args.kwargs["event"] @@ -53,26 +53,18 @@ def test_run_action(): @pytest.mark.parametrize("unit_id", (1, 2, 42)) def test_app_name(app_name, unit_id): ctx = Context(MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id) - with ctx.manager(ctx.on.start(), State()) as mgr: + with ctx(ctx.on.start(), State()) as mgr: assert mgr.charm.app.name == app_name assert mgr.charm.unit.name == f"{app_name}/{unit_id}" -def test_action_output_no_positional_arguments(): - with pytest.raises(TypeError): - ActionOutput(None, None) - - -def test_action_output_no_results(): - class MyCharm(CharmBase): - def __init__(self, framework): - super().__init__(framework) - framework.observe(self.on.act_action, self._on_act_action) - - def _on_act_action(self, _): - pass - +def test_context_manager(): ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) - out = ctx.run_action(ctx.on.action("act"), State()) - assert out.results is None - assert out.failure is None + state = State() + with ctx(ctx.on.start(), state) as mgr: + mgr.run() + assert mgr.charm.meta.name == "foo" + + with ctx(ctx.on.action("act"), state) as mgr: + mgr.run() + assert mgr.charm.meta.name == "foo" diff --git a/tests/test_context_on.py b/tests/test_context_on.py index 8ddbf4d4..32759fd4 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -64,7 +64,7 @@ def test_simple_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.install(), state) - with ctx.manager(getattr(ctx.on, event_name)(), scenario.State()) as mgr: + with ctx(getattr(ctx.on, event_name)(), scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) @@ -93,13 +93,13 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): else: args = (secret,) kwargs = {} - with ctx.manager(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr: + with ctx(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.secret.id == secret.id + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.secret.id == secret.id @pytest.mark.parametrize( @@ -121,14 +121,14 @@ def test_revision_secret_events(event_name, event_kind): # ctx.run(ctx.on.secret_expired(secret=secret, revision=revision), state) # The secret and revision must always be passed because the same event name # is used for all secrets. - with ctx.manager(getattr(ctx.on, event_name)(secret, revision=42), state_in) as mgr: + with ctx(getattr(ctx.on, event_name)(secret, revision=42), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.secret.id == secret.id - assert event.revision == 42 + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.secret.id == secret.id + assert mgr.revision == 42 @pytest.mark.parametrize("event_name", ["secret_expired", "secret_remove"]) @@ -157,42 +157,42 @@ def test_storage_events(event_name, event_kind): state_in = scenario.State(storages=[storage]) # These look like: # ctx.run(ctx.on.storage_attached(storage), state) - with ctx.manager(getattr(ctx.on, event_name)(storage), state_in) as mgr: + with ctx(getattr(ctx.on, event_name)(storage), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.storage.name == storage.name - assert event.storage.index == storage.index + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.storage.name == storage.name + assert mgr.storage.index == storage.index def test_action_event_no_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: - # ctx.run_action(ctx.on.action(action), state) - with ctx.action_manager(ctx.on.action("act"), scenario.State()) as mgr: + # ctx.run(ctx.on.action(action_name), state) + with ctx(ctx.on.action("act"), scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.ActionEvent) + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.ActionEvent) def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: - # ctx.run_action(ctx.on.action(action=action), state) + # ctx.run(ctx.on.action(action=action), state) # So that any parameters can be included and the ID can be customised. call_event = ctx.on.action("act", params={"param": "hello"}) - with ctx.action_manager(call_event, scenario.State()) as mgr: + with ctx(call_event, scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.ActionEvent) - assert event.id == call_event.action.id - assert event.params["param"] == call_event.action.params["param"] + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.ActionEvent) + assert mgr.id == call_event.action.id + assert mgr.params["param"] == call_event.action.params["param"] def test_pebble_ready_event(): @@ -201,13 +201,13 @@ def test_pebble_ready_event(): state_in = scenario.State(containers=[container]) # These look like: # ctx.run(ctx.on.pebble_ready(container), state) - with ctx.manager(ctx.on.pebble_ready(container), state_in) as mgr: + with ctx(ctx.on.pebble_ready(container), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.PebbleReadyEvent) - assert event.workload.name == container.name + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.PebbleReadyEvent) + assert mgr.workload.name == container.name @pytest.mark.parametrize("as_kwarg", [True, False]) @@ -230,15 +230,15 @@ def test_relation_app_events(as_kwarg, event_name, event_kind): else: args = (relation,) kwargs = {} - with ctx.manager(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr: + with ctx(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit is None + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit is None def test_relation_complex_name(): @@ -247,14 +247,14 @@ def test_relation_complex_name(): ctx = scenario.Context(ContextCharm, meta=meta, actions=ACTIONS) relation = scenario.Relation("foo-bar-baz") state_in = scenario.State(relations=[relation]) - with ctx.manager(ctx.on.relation_created(relation), state_in) as mgr: + with ctx(ctx.on.relation_created(relation), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 - event = mgr.charm.observed[0] - assert isinstance(event, ops.RelationCreatedEvent) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit is None + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.RelationCreatedEvent) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit is None @pytest.mark.parametrize("event_name", ["relation_created", "relation_broken"]) @@ -280,15 +280,15 @@ def test_relation_unit_events_default_unit(event_name, event_kind): # These look like: # ctx.run(ctx.on.baz_relation_changed, state) # The unit is chosen automatically. - with ctx.manager(getattr(ctx.on, event_name)(relation), state_in) as mgr: + with ctx(getattr(ctx.on, event_name)(relation), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit.name == "remote/1" + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit.name == "remote/1" @pytest.mark.parametrize( @@ -306,17 +306,15 @@ def test_relation_unit_events(event_name, event_kind): state_in = scenario.State(relations=[relation]) # These look like: # ctx.run(ctx.on.baz_relation_changed(unit=unit_ordinal), state) - with ctx.manager( - getattr(ctx.on, event_name)(relation, remote_unit=2), state_in - ) as mgr: + with ctx(getattr(ctx.on, event_name)(relation, remote_unit=2), state_in) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit.name == "remote/2" + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit.name == "remote/2" def test_relation_departed_event(): @@ -325,15 +323,15 @@ def test_relation_departed_event(): state_in = scenario.State(relations=[relation]) # These look like: # ctx.run(ctx.on.baz_relation_departed(unit=unit_ordinal, departing_unit=unit_ordinal), state) - with ctx.manager( + with ctx( ctx.on.relation_departed(relation, remote_unit=2, departing_unit=1), state_in ) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.RelationDepartedEvent) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit.name == "remote/2" - assert event.departing_unit.name == "remote/1" + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.RelationDepartedEvent) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit.name == "remote/2" + assert mgr.departing_unit.name == "remote/1" diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index b0668355..7b6d1727 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -3,8 +3,7 @@ from ops.charm import ActionEvent, CharmBase from ops.framework import Framework -from scenario import Context -from scenario.context import InvalidEventError +from scenario import ActionFailed, Context from scenario.state import State, _Action, next_action_id @@ -34,14 +33,29 @@ def test_action_event(mycharm, baz_value): "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} }, ) - ctx.run_action(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State()) + state = ctx.run(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State()) + assert isinstance(state, State) evt = ctx.emitted_events[0] - assert evt.params["bar"] == 10 assert evt.params["baz"] is baz_value +def test_action_no_results(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.act_action, self._on_act_action) + + def _on_act_action(self, _): + pass + + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) + ctx.run(ctx.on.action("act"), State()) + assert ctx.action_results is None + assert ctx.action_logs == [] + + @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) def test_action_event_results_invalid(mycharm, res_value): def handle_evt(charm: CharmBase, evt: ActionEvent): @@ -51,19 +65,12 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(ctx.on.action("foo"), State()) - - -def test_cannot_run_action(mycharm): - ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - - with pytest.raises(InvalidEventError): - ctx.run(ctx.on.action("foo"), state=State()) + ctx.run(ctx.on.action("foo"), State()) @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_results_valid(mycharm, res_value): - def handle_evt(charm: CharmBase, evt): + def handle_evt(_: CharmBase, evt): if not isinstance(evt, ActionEvent): return evt.set_results(res_value) @@ -74,15 +81,14 @@ def handle_evt(charm: CharmBase, evt): ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(ctx.on.action("foo"), State()) + ctx.run(ctx.on.action("foo"), State()) - assert out.results == res_value - assert out.success is True + assert ctx.action_results == res_value @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_outputs(mycharm, res_value): - def handle_evt(charm: CharmBase, evt: ActionEvent): + def handle_evt(_: CharmBase, evt: ActionEvent): if not isinstance(evt, ActionEvent): return @@ -94,11 +100,31 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(ctx.on.action("foo"), State()) + with pytest.raises(ActionFailed) as exc_info: + ctx.run(ctx.on.action("foo"), State()) + assert exc_info.value.message == "failed becozz" + assert ctx.action_results == {"my-res": res_value} + assert ctx.action_logs == ["log1", "log2"] + + +def test_action_continues_after_fail(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_action, self._on_foo_action) + + def _on_foo_action(self, event): + event.log("starting") + event.set_results({"initial": "result"}) + event.fail("oh no!") + event.set_results({"final": "result"}) - assert out.failure == "failed becozz" - assert out.logs == ["log1", "log2"] - assert out.success is False + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) + with pytest.raises(ActionFailed) as exc_info: + ctx.run(ctx.on.action("foo"), State()) + assert exc_info.value.message == "oh no!" + assert ctx.action_logs == ["starting"] + assert ctx.action_results == {"initial": "result", "final": "result"} def _ops_less_than(wanted_major, wanted_minor): @@ -114,7 +140,7 @@ def _ops_less_than(wanted_major, wanted_minor): _ops_less_than(2, 11), reason="ops 2.10 and earlier don't have ActionEvent.id" ) def test_action_event_has_id(mycharm): - def handle_evt(charm: CharmBase, evt: ActionEvent): + def handle_evt(_: CharmBase, evt: ActionEvent): if not isinstance(evt, ActionEvent): return assert isinstance(evt.id, str) and evt.id != "" @@ -122,7 +148,7 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(ctx.on.action("foo"), State()) + ctx.run(ctx.on.action("foo"), State()) @pytest.mark.skipif( @@ -139,7 +165,32 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(ctx.on.action("foo", id=uuid), State()) + ctx.run(ctx.on.action("foo", id=uuid), State()) + + +def test_two_actions_same_context(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_action, self._on_foo_action) + framework.observe(self.on.bar_action, self._on_bar_action) + + def _on_foo_action(self, event): + event.log("foo") + event.set_results({"foo": "result"}) + + def _on_bar_action(self, event): + event.log("bar") + event.set_results({"bar": "result"}) + + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}, "bar": {}}) + ctx.run(ctx.on.action("foo"), State()) + assert ctx.action_results == {"foo": "result"} + assert ctx.action_logs == ["foo"] + # Not recommended, but run another action in the same context. + ctx.run(ctx.on.action("bar"), State()) + assert ctx.action_results == {"bar": "result"} + assert ctx.action_logs == ["bar"] def test_positional_arguments(): diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 1834b3da..c3a09248 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -47,14 +47,14 @@ def test_get_cloud_spec(): name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec ), ) - with ctx.manager(ctx.on.start(), state=state) as mgr: + with ctx(ctx.on.start(), state=state) as mgr: assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec def test_get_cloud_spec_error(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state = scenario.State(model=scenario.Model(name="lxd-model", type="lxd")) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() @@ -65,6 +65,6 @@ def test_get_cloud_spec_untrusted(): state = scenario.State( model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec), ) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 3f99ffd0..1856c7ae 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -5,7 +5,7 @@ from ops.charm import CharmBase, CollectStatusEvent from scenario import Context, State -from scenario.context import ActionOutput, AlreadyEmittedError, _EventManager +from scenario.context import AlreadyEmittedError, Manager @pytest.fixture(scope="function") @@ -23,7 +23,6 @@ def _on_event(self, e): if isinstance(e, CollectStatusEvent): return - print("event!") self.unit.status = ActiveStatus(e.handle.kind) return MyCharm @@ -31,41 +30,34 @@ def _on_event(self, e): def test_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, ctx.on.start(), State()) as manager: + with Manager(ctx, ctx.on.start(), State()) as manager: assert isinstance(manager.charm, mycharm) - assert not manager.output state_out = manager.run() - assert manager.output is state_out assert isinstance(state_out, State) - assert manager.output # still there! def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, ctx.on.start(), State()) as manager: + with Manager(ctx, ctx.on.start(), State()) as manager: assert isinstance(manager.charm, mycharm) # do not call .run() # run is called automatically assert manager._emitted - assert manager.output - assert manager.output.unit_status == ActiveStatus("start") def test_manager_reemit_fails(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, ctx.on.start(), State()) as manager: + with Manager(ctx, ctx.on.start(), State()) as manager: manager.run() with pytest.raises(AlreadyEmittedError): manager.run() - assert manager.output - def test_context_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with ctx.manager(ctx.on.start(), State()) as manager: + with ctx(ctx.on.start(), State()) as manager: state_out = manager.run() assert isinstance(state_out, State) assert ctx.emitted_events[0].handle.kind == "start" @@ -73,7 +65,7 @@ def test_context_manager(mycharm): def test_context_action_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS) - with ctx.action_manager(ctx.on.action("do-x"), State()) as manager: - ao = manager.run() - assert isinstance(ao, ActionOutput) + with ctx(ctx.on.action("do-x"), State()) as manager: + state_out = manager.run() + assert isinstance(state_out, State) assert ctx.emitted_events[0].handle.kind == "do_x_action" diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 761e9c71..a09d09f6 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -47,7 +47,7 @@ def test_ip_get(mycharm): }, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[ @@ -84,7 +84,7 @@ def test_no_sub_binding(mycharm): }, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[ @@ -109,7 +109,7 @@ def test_no_relation_error(mycharm): }, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[ diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index da40cf5d..dec93c4d 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -128,7 +128,7 @@ def callback(self: CharmBase): charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, ) - with ctx.manager(ctx.on.start(), state=state) as mgr: + with ctx(ctx.on.start(), state=state) as mgr: out = mgr.run() callback(mgr.charm) @@ -318,7 +318,7 @@ def test_exec_wait_error(charm_cls): ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) with pytest.raises(ExecError): @@ -340,7 +340,7 @@ def test_exec_wait_output(charm_cls): ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) out, err = proc.wait_output() @@ -360,7 +360,7 @@ def test_exec_wait_output_error(charm_cls): ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) with pytest.raises(ExecError): @@ -381,7 +381,7 @@ def test_pebble_custom_notice(charm_cls): state = State(containers=[container]) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager( + with ctx( ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state ) as mgr: container = mgr.charm.unit.get_container("foo") diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index 80365a01..9e46665a 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -2,7 +2,7 @@ from ops import CharmBase, Framework, StartEvent, StopEvent from scenario import Context, State -from scenario.state import StateValidationError, TCPPort, UDPPort, _Port +from scenario.state import Port, StateValidationError, TCPPort, UDPPort class MyCharm(CharmBase): @@ -42,7 +42,7 @@ def test_close_port(ctx): def test_port_no_arguments(): with pytest.raises(RuntimeError): - _Port() + Port() @pytest.mark.parametrize("klass", (TCPPort, UDPPort)) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 9ba0ed61..44433e21 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -17,10 +17,10 @@ DEFAULT_JUJU_DATABAG, PeerRelation, Relation, + RelationBase, State, StateValidationError, SubordinateRelation, - _RelationBase, next_relation_id, ) from tests.helpers import trigger @@ -399,7 +399,7 @@ def post_event(charm: CharmBase): def test_cannot_instantiate_relationbase(): with pytest.raises(RuntimeError): - _RelationBase("") + RelationBase("") def test_relation_ids(): @@ -417,7 +417,7 @@ def test_broken_relation_not_in_model_relations(mycharm): ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} ) - with ctx.manager(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: + with ctx(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: charm = mgr.charm assert charm.model.get_relation("foo") is None diff --git a/tests/test_e2e/test_resource.py b/tests/test_e2e/test_resource.py index c4237ea6..aebe8a0d 100644 --- a/tests/test_e2e/test_resource.py +++ b/tests/test_e2e/test_resource.py @@ -25,7 +25,7 @@ def test_get_resource(): ) resource1 = Resource(name="foo", path=pathlib.Path("/tmp/foo")) resource2 = Resource(name="bar", path=pathlib.Path("~/bar")) - with ctx.manager( + with ctx( ctx.on.update_status(), state=State(resources={resource1, resource2}) ) as mgr: assert mgr.charm.model.resources.fetch("foo") == resource1.path diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 710efd61..d369a711 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -34,7 +34,7 @@ def _on_event(self, event): def test_get_secret_no_secret(mycharm): ctx = Context(mycharm, meta={"name": "local"}) - with ctx.manager(ctx.on.update_status(), State()) as mgr: + with ctx(ctx.on.update_status(), State()) as mgr: with pytest.raises(SecretNotFoundError): assert mgr.charm.model.get_secret(id="foo") with pytest.raises(SecretNotFoundError): @@ -45,7 +45,7 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) secret = Secret({"a": "b"}, owner=owner) - with ctx.manager( + with ctx( state=State(secrets={secret}), event=ctx.on.update_status(), ) as mgr: @@ -60,7 +60,7 @@ def test_get_secret_get_refresh(mycharm, owner): latest_content={"a": "c"}, owner=owner, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State(secrets={secret}), ) as mgr: @@ -77,7 +77,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): tracked_content={"a": "b"}, latest_content={"a": "c"}, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( leader=app, @@ -104,7 +104,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): latest_content={"a": "c"}, owner=owner, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( secrets={secret}, @@ -156,7 +156,7 @@ def test_consumer_events_failures(mycharm, evt_suffix, revision): @pytest.mark.parametrize("app", (True, False)) def test_add(mycharm, app): ctx = Context(mycharm, meta={"name": "local"}) - with ctx.manager( + with ctx( ctx.on.update_status(), State(leader=app), ) as mgr: @@ -165,9 +165,10 @@ def test_add(mycharm, app): charm.app.add_secret({"foo": "bar"}, label="mylabel") else: charm.unit.add_secret({"foo": "bar"}, label="mylabel") + output = mgr.run() - assert mgr.output.secrets - secret = mgr.output.get_secret(label="mylabel") + assert output.secrets + secret = output.get_secret(label="mylabel") assert secret.latest_content == secret.tracked_content == {"foo": "bar"} assert secret.label == "mylabel" @@ -177,7 +178,7 @@ def test_set_legacy_behaviour(mycharm): # ref: https://bugs.launchpad.net/juju/+bug/2037120 ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6") rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} - with ctx.manager( + with ctx( ctx.on.update_status(), State(), ) as mgr: @@ -213,7 +214,7 @@ def test_set_legacy_behaviour(mycharm): def test_set(mycharm): ctx = Context(mycharm, meta={"name": "local"}) rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} - with ctx.manager( + with ctx( ctx.on.update_status(), State(), ) as mgr: @@ -245,7 +246,7 @@ def test_set(mycharm): def test_set_juju33(mycharm): ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1") rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} - with ctx.manager( + with ctx( ctx.on.update_status(), State(), ) as mgr: @@ -277,7 +278,7 @@ def test_meta(mycharm, app): description="foobarbaz", rotate=SecretRotate.HOURLY, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( leader=True, @@ -314,7 +315,7 @@ def test_secret_permission_model(mycharm, leader, owner): rotate=SecretRotate.HOURLY, ) secret_id = secret.id - with ctx.manager( + with ctx( ctx.on.update_status(), State( leader=leader, @@ -361,7 +362,7 @@ def test_grant(mycharm, app): description="foobarbaz", rotate=SecretRotate.HOURLY, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[Relation("foo", "remote")], @@ -375,7 +376,8 @@ def test_grant(mycharm, app): secret.grant(relation=foo) else: secret.grant(relation=foo, unit=foo.units.pop()) - vals = list(mgr.output.get_secret(label="mylabel").remote_grants.values()) + output = mgr.run() + vals = list(output.get_secret(label="mylabel").remote_grants.values()) assert vals == [{"remote"}] if app else [{"remote/0"}] @@ -388,7 +390,7 @@ def test_update_metadata(mycharm): owner="unit", label="mylabel", ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( secrets={secret}, @@ -401,8 +403,9 @@ def test_update_metadata(mycharm): expire=exp, rotate=SecretRotate.DAILY, ) + output = mgr.run() - secret_out = mgr.output.get_secret(label="babbuccia") + secret_out = output.get_secret(label="babbuccia") assert secret_out.label == "babbuccia" assert secret_out.rotate == SecretRotate.DAILY assert secret_out.description == "blu" @@ -476,32 +479,35 @@ class GrantingCharm(CharmBase): }, ) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: charm = mgr.charm secret = charm.app.add_secret({"foo": "bar"}, label="mylabel") bar_relation = charm.model.relations["bar"][0] secret.grant(bar_relation) + output = mgr.run() - assert mgr.output.secrets - scenario_secret = mgr.output.get_secret(label="mylabel") + assert output.secrets + scenario_secret = output.get_secret(label="mylabel") assert relation_remote_app in scenario_secret.remote_grants[relation_id] - with ctx.manager(ctx.on.start(), mgr.output) as mgr: + with ctx(ctx.on.start(), output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) + output = mgr.run() - scenario_secret = mgr.output.get_secret(label="mylabel") + scenario_secret = output.get_secret(label="mylabel") assert scenario_secret.remote_grants == {} - with ctx.manager(ctx.on.start(), mgr.output) as mgr: + with ctx(ctx.on.start(), output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() + output = mgr.run() with pytest.raises(KeyError): - mgr.output.get_secret(label="mylabel") + output.get_secret(label="mylabel") def test_secret_removed_event(): diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index 3e6912fb..34785aa1 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -23,13 +23,13 @@ def no_storage_ctx(): def test_storage_get_null(no_storage_ctx): - with no_storage_ctx.manager(no_storage_ctx.on.update_status(), State()) as mgr: + with no_storage_ctx(no_storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages assert not len(storages) def test_storage_get_unknown_name(storage_ctx): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(KeyError): @@ -37,7 +37,7 @@ def test_storage_get_unknown_name(storage_ctx): def test_storage_request_unknown_name(storage_ctx): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(ModelError): @@ -45,7 +45,7 @@ def test_storage_request_unknown_name(storage_ctx): def test_storage_get_some(storage_ctx): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # known but none attached assert storages["foo"] == [] @@ -53,7 +53,7 @@ def test_storage_get_some(storage_ctx): @pytest.mark.parametrize("n", (1, 3, 5)) def test_storage_add(storage_ctx, n): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages storages.request("foo", n) @@ -65,9 +65,7 @@ def test_storage_usage(storage_ctx): # setup storage with some content (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") - with storage_ctx.manager( - storage_ctx.on.update_status(), State(storages={storage}) - ) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State(storages={storage})) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index c8702611..fa8a3d41 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -56,7 +56,7 @@ def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): meta_file.write_text(raw_ori_meta) ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) - with ctx.manager( + with ctx( ctx.on.start(), State(), ) as mgr: @@ -79,7 +79,7 @@ def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root): assert not meta_file.exists() ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) - with ctx.manager( + with ctx( ctx.on.start(), State(), ) as mgr: From 62fdfc03471c2569c704607868bc62c3771389fc Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 13 Aug 2024 10:20:29 +1200 Subject: [PATCH 26/35] fix: update secret label when getting with both id and label (#172) When we get a secret and provide both ID and label, the label should update to the provided one. This was previously missing in Scenario. Fixes #95 --- scenario/mocking.py | 6 ++++++ scenario/state.py | 5 +++++ tests/test_e2e/test_secrets.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/scenario/mocking.py b/scenario/mocking.py index 04f5a873..8452b314 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -404,6 +404,9 @@ def secret_get( peek: bool = False, ) -> Dict[str, str]: secret = self._get_secret(id, label) + # If both the id and label are provided, then update the label. + if id is not None and label is not None: + secret._set_label(label) juju_version = self._context.juju_version if not (juju_version == "3.1.7" or juju_version >= "3.3.1"): # In this medieval Juju chapter, @@ -427,6 +430,9 @@ def secret_info_get( label: Optional[str] = None, ) -> SecretInfo: secret = self._get_secret(id, label) + # If both the id and label are provided, then update the label. + if id is not None and label is not None: + secret._set_label(label) # only "manage"=write access level can read secret info self._check_can_manage_secret(secret) diff --git a/scenario/state.py b/scenario/state.py index d1eb108a..ded75798 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -323,6 +323,10 @@ def __post_init__(self): # bypass frozen dataclass object.__setattr__(self, "latest_content", self.tracked_content) + def _set_label(self, label): + # bypass frozen dataclass + object.__setattr__(self, "label", label) + def _track_latest_revision(self): """Set the current revision to the tracked revision.""" # bypass frozen dataclass @@ -342,6 +346,7 @@ def _update_metadata( object.__setattr__(self, "_latest_revision", self._latest_revision + 1) # TODO: if this is done twice in the same hook, then Juju ignores the # first call, it doesn't continue to update like this does. + # Fix when https://github.com/canonical/operator/issues/1288 is resolved. if content: object.__setattr__(self, "latest_content", content) if label: diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index d369a711..fb1590b7 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -601,6 +601,24 @@ def _on_event(self, event): assert isinstance(juju_event, cls) +def test_set_label_on_get(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + id = self.unit.add_secret({"foo": "bar"}).id + secret = self.model.get_secret(id=id, label="label1") + assert secret.label == "label1" + secret = self.model.get_secret(id=id, label="label2") + assert secret.label == "label2" + + ctx = Context(SecretCharm, meta={"name": "foo"}) + state = ctx.run(ctx.on.start(), State()) + assert state.get_secret(label="label2").tracked_content == {"foo": "bar"} + + def test_no_additional_positional_arguments(): with pytest.raises(TypeError): Secret({}, {}, None) From 190d3c03d3413e6bd2e9fb8087da100dd24e6c31 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 20 Aug 2024 16:11:44 +1200 Subject: [PATCH 27/35] feat!: simplify creating deferred events (#174) A couple of changes to simplify setting up the queue of deferred events: * Adjusted the docs for `DeferredEvent` to strongly suggest that it's for internal use only and people should use the `.deferred()` method of an `_Event` instead. It's still required internally (to go to/from the ops state) and might be needed in obscure cases, but this reduces the number of ways people need to be aware of by one. * The `scenario.deferred()` helper is removed. When using this method for events that link to components (relations, containers, etc) it was possible to not have the event set up correctly, and it didn't really seem to offer much over `.deferred()`. Removing it leaves one normal way to create deferred events. Finally, setting up the `DeferredEvent` snapshot data only handled workload events and relation events (and wasn't always correct for all relation events). It should now cover all current events with the right snapshot. --- README.md | 56 ++----------------- scenario/__init__.py | 2 - scenario/state.py | 98 +++++++++++++++++++++------------ tests/test_e2e/test_deferred.py | 51 +---------------- 4 files changed, 71 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 0e2332ff..18c82842 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ with scenario.capture_events.capture_events() as emitted: ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( ctx.on.update_status(), - scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) + scenario.State(deferred=[ctx.on.start().deferred(SimpleCharm._on_start)]) ) # deferred events get reemitted first @@ -1050,7 +1050,7 @@ def test_backup_action(): Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is -valid. +valid. You generate the deferred data structure using the event's `deferred()` method: ```python class MyCharm(ops.CharmBase): @@ -1063,28 +1063,19 @@ class MyCharm(ops.CharmBase): event.defer() -def test_start_on_deferred_update_status(MyCharm): +def test_start_on_deferred_update_status(): """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" + ctx = scenario.Context(MyCharm) state_in = scenario.State( deferred=[ - scenario.deferred('update_status', handler=MyCharm._on_update_status) + ctx.on.update_status().deferred(handler=MyCharm._on_update_status) ] ) - state_out = scenario.Context(MyCharm).run(ctx.on.start(), state_in) + state_out = ctx.run(ctx.on.start(), state_in) assert len(state_out.deferred) == 1 assert state_out.deferred[0].name == 'start' ``` -You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the -handler): - -```python continuation -ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) - -deferred_start = ctx.on.start().deferred(MyCharm._on_start) -deferred_install = ctx.on.install().deferred(MyCharm._on_start) -``` - On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. @@ -1102,41 +1093,6 @@ def test_defer(MyCharm): assert out.deferred[0].name == 'start' ``` -## Deferring relation events - -If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the -Relation instance they are about. So do they in Scenario. You can use the deferred helper to generate the data -structure: - -```python -class MyCharm(ops.CharmBase): - ... - - def _on_foo_relation_changed(self, event): - event.defer() - - -def test_start_on_deferred_update_status(MyCharm): - foo_relation = scenario.Relation('foo') - scenario.State( - relations={foo_relation}, - deferred=[ - scenario.deferred('foo_relation_changed', - handler=MyCharm._on_foo_relation_changed, - relation=foo_relation) - ] - ) -``` - -but you can also use a shortcut from the relation event itself: - -```python continuation -ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) - -foo_relation = scenario.Relation('foo') -deferred_event = ctx.on.relation_changed(foo_relation).deferred(handler=MyCharm._on_foo_relation_changed) -``` - # Live charm introspection Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to diff --git a/scenario/__init__.py b/scenario/__init__.py index 24c1cac0..8981d3b3 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -35,7 +35,6 @@ UDPPort, UnknownStatus, WaitingStatus, - deferred, ) __all__ = [ @@ -44,7 +43,6 @@ "CloudCredential", "CloudSpec", "Context", - "deferred", "StateValidationError", "Secret", "Relation", diff --git a/scenario/state.py b/scenario/state.py index ded75798..45cb6ea8 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1571,8 +1571,13 @@ 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. - In most cases, the :func:`deferred` function should be used to create a - ``DeferredEvent`` instance.""" + Tests should not instantiate this class directly: use :meth:`_Event.deferred` + instead. For example: + + ctx = Context(MyCharm) + deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) + state = State(deferred=[deferred_start]) + """ handle_path: str owner: str @@ -1581,6 +1586,12 @@ class DeferredEvent: # needs to be marshal.dumps-able. snapshot_data: Dict = dataclasses.field(default_factory=dict) + # It would be nicer if people could do something like: + # `isinstance(state.deferred[0], ops.StartEvent)` + # than comparing with the string names, but there's only one `_Event` + # class in Scenario, and it also needs to be created from the context, + # which is not available here. For the ops classes, it's complex to create + # them because they need a Handle. @property def name(self): return self.handle_path.split("/")[-1].split("[")[0] @@ -1789,17 +1800,18 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: owner_name, handler_name = match.groups()[0].split(".")[-2:] handle_path = f"{owner_name}/on/{self.name}[{event_id}]" + # Many events have no snapshot data: install, start, stop, remove, config-changed, + # upgrade-charm, pre-series-upgrade, post-series-upgrade, leader-elected, + # leader-settings-changed, collect-metrics snapshot_data = {} # fixme: at this stage we can't determine if the event is a builtin one or not; if it is # not, then the coming checks are meaningless: the custom event could be named like a # relation event but not *be* one. if self._is_workload_event: - # this is a WorkloadEvent. The snapshot: - container = cast(Container, self.container) - snapshot_data = { - "container_name": container.name, - } + # Enforced by the consistency checker, but for type checkers: + assert self.container is not None + snapshot_data["container_name"] = self.container.name if self.notice: if hasattr(self.notice.type, "value"): notice_type = cast(pebble.NoticeType, self.notice.type).value @@ -1816,8 +1828,9 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data["check_name"] = self.check_info.name elif self._is_relation_event: - # this is a RelationEvent. - relation = cast("AnyRelation", self.relation) + # Enforced by the consistency checker, but for type checkers: + assert self.relation is not None + relation = self.relation if isinstance(relation, PeerRelation): # FIXME: relation.unit for peers should point to , but we # don't have access to the local app name in this context. @@ -1825,12 +1838,46 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: else: remote_app = relation.remote_app_name - snapshot_data = { - "relation_name": relation.endpoint, - "relation_id": relation.id, - "app_name": remote_app, - "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", - } + snapshot_data.update( + { + "relation_name": relation.endpoint, + "relation_id": relation.id, + "app_name": remote_app, + }, + ) + if not self.name.endswith(("_created", "_broken")): + snapshot_data[ + "unit_name" + ] = f"{remote_app}/{self.relation_remote_unit_id}" + if self.name.endswith("_departed"): + snapshot_data[ + "departing_unit" + ] = f"{remote_app}/{self.relation_departed_unit_id}" + + elif self._is_storage_event: + # Enforced by the consistency checker, but for type checkers: + assert self.storage is not None + snapshot_data.update( + { + "storage_name": self.storage.name, + "storage_index": self.storage.index, + # "storage_location": str(self.storage.get_filesystem(self._context)), + }, + ) + + elif self._is_secret_event: + # Enforced by the consistency checker, but for type checkers: + assert self.secret is not None + snapshot_data.update( + {"secret_id": self.secret.id, "secret_label": self.secret.label}, + ) + if self.name.endswith(("_remove", "_expired")): + snapshot_data["secret_revision"] = self.secret_revision + + elif self._is_action_event: + # Enforced by the consistency checker, but for type checkers: + assert self.action is not None + snapshot_data["id"] = self.action.id return DeferredEvent( handle_path, @@ -1879,24 +1926,3 @@ def test_backup_action(): Every action invocation is automatically assigned a new one. Override in the rare cases where a specific ID is required.""" - - -def deferred( - event: Union[str, _Event], - handler: Callable, - event_id: int = 1, - relation: Optional["Relation"] = None, - container: Optional["Container"] = None, - notice: Optional["Notice"] = None, - check_info: Optional["CheckInfo"] = None, -): - """Construct a DeferredEvent from an Event or an event name.""" - if isinstance(event, str): - event = _Event( - event, - relation=relation, - container=container, - notice=notice, - check_info=check_info, - ) - return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 2b21dd90..44e21fce 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -12,7 +12,7 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Container, Notice, Relation, State, _Event, deferred +from scenario.state import Container, Notice, Relation, State, _Event from tests.helpers import trigger CHARM_CALLED = 0 @@ -54,7 +54,7 @@ def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 out = trigger( - State(deferred=[deferred(event="update_status", handler=mycharm._on_event)]), + State(deferred=[_Event("update_status").deferred(handler=mycharm._on_event)]), "start", mycharm, meta=mycharm.META, @@ -72,49 +72,6 @@ def test_deferred_evt_emitted(mycharm): assert isinstance(start, StartEvent) -def test_deferred_relation_event_without_relation_raises(mycharm): - with pytest.raises(AttributeError): - deferred(event="foo_relation_changed", handler=mycharm._on_event) - - -def test_deferred_relation_evt(mycharm): - rel = Relation(endpoint="foo", remote_app_name="remote") - evt1 = _Event("foo_relation_changed", relation=rel).deferred( - handler=mycharm._on_event - ) - evt2 = deferred( - event="foo_relation_changed", - handler=mycharm._on_event, - relation=rel, - ) - - assert asdict(evt2) == asdict(evt1) - - -def test_deferred_workload_evt(mycharm): - ctr = Container("foo") - evt1 = _Event("foo_pebble_ready", container=ctr).deferred(handler=mycharm._on_event) - evt2 = deferred(event="foo_pebble_ready", handler=mycharm._on_event, container=ctr) - - assert asdict(evt2) == asdict(evt1) - - -def test_deferred_notice_evt(mycharm): - notice = Notice(key="example.com/bar") - ctr = Container("foo", notices=[notice]) - evt1 = _Event("foo_pebble_custom_notice", notice=notice, container=ctr).deferred( - handler=mycharm._on_event - ) - evt2 = deferred( - event="foo_pebble_custom_notice", - handler=mycharm._on_event, - container=ctr, - notice=notice, - ) - - assert asdict(evt2) == asdict(evt1) - - def test_deferred_relation_event(mycharm): mycharm.defer_next = 2 @@ -124,10 +81,8 @@ def test_deferred_relation_event(mycharm): State( relations={rel}, deferred=[ - deferred( - event="foo_relation_changed", + _Event("foo_relation_changed", relation=rel).deferred( handler=mycharm._on_event, - relation=rel, ) ], ), From abe24c219afc2c299a0edd78b4e947e4d20496aa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 20 Aug 2024 18:19:31 +1200 Subject: [PATCH 28/35] refactor!: rename Container.exec_mocks to Container.execs and extend mocking (#124) Firstly, some simple renaming: * `Container.exec_mocks` becomes `Container.execs` * `Container.service_status` becomes `Container.service_statuses` * `ExecOutput` becomes `Exec` A behaviour change that is a bugfix: * When a process exits non-zero, the `ExecError` should have the stdout/stderr if `wait_output` was used (it's not readable afterwards by the charm, although the Scenario code doesn't enforce that). Some more substantial changes: * Provide the ability to get the exact command, the stdin, and all the other args that the charm used with the process (everything from os.testing's `ExecArgs`), via a new context attribute `exec_history`, which is a (default) dict where the key is the container name and the value is a list of Pebble exec's that have been run. * Support the same "find the closest match to this prefix" system for commands as Harness does We could add more of the functionality that Harness has, but I think this is a solid subset (I've wanted to be able to get to the stdin in writing tests myself, and the simple mock matching seems handy). It should be easy enough to extend in the future without needing API changes, I think, since this now has both input and output. The key parts that are missing are properly supporting binary IO and the execution context. --------- Co-authored-by: PietroPasotti --- README.md | 23 ++++- scenario/__init__.py | 4 +- scenario/consistency_checker.py | 38 ++++--- scenario/context.py | 2 + scenario/mocking.py | 161 ++++++++++++++++++++++++------ scenario/state.py | 55 +++++++--- tests/test_consistency_checker.py | 14 ++- tests/test_e2e/test_pebble.py | 55 +++++++--- tests/test_e2e/test_state.py | 4 +- 9 files changed, 275 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 18c82842..bab3ab89 100644 --- a/README.md +++ b/README.md @@ -639,6 +639,7 @@ class MyCharm(ops.CharmBase): def _on_start(self, _): foo = self.unit.get_container('foo') proc = foo.exec(['ls', '-ll']) + proc.stdin.write("...") stdout, _ = proc.wait_output() assert stdout == LS_LL @@ -646,10 +647,12 @@ class MyCharm(ops.CharmBase): def test_pebble_exec(): container = scenario.Container( name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - scenario.ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) + execs={ + scenario.Exec( + command_prefix=['ls'], + return_code=0, + stdout=LS_LL, + ), } ) state_in = scenario.State(containers={container}) @@ -661,8 +664,20 @@ def test_pebble_exec(): ctx.on.pebble_ready(container), state_in, ) + assert ctx.exec_history[container.name][0].command == ['ls', '-ll'] + assert ctx.exec_history[container.name][0].stdin == "..." ``` +Scenario will attempt to find the right `Exec` object by matching the provided +command prefix against the command used in the ops `container.exec()` call. For +example if the command is `['ls', '-ll']` then the searching will be: + + 1. an `Exec` with exactly the same as command prefix, `('ls', '-ll')` + 2. an `Exec` with the command prefix `('ls', )` + 3. an `Exec` with the command prefix `()` + +If none of these are found Scenario will raise an `ExecError`. + ### Pebble Notices Pebble can generate notices, which Juju will detect, and wake up the charm to diff --git a/scenario/__init__.py b/scenario/__init__.py index 8981d3b3..1b9416f8 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -14,7 +14,7 @@ Container, DeferredEvent, ErrorStatus, - ExecOutput, + Exec, ICMPPort, MaintenanceStatus, Model, @@ -49,7 +49,7 @@ "SubordinateRelation", "PeerRelation", "Model", - "ExecOutput", + "Exec", "Mount", "Container", "Notice", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index e319a107..c2205540 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -4,7 +4,7 @@ import marshal import os import re -from collections import defaultdict +from collections import Counter, defaultdict from collections.abc import Sequence from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union @@ -186,27 +186,33 @@ def _check_workload_event( event: "_Event", state: "State", errors: List[str], - warnings: List[str], # noqa: U100 + warnings: List[str], ): if not event.container: errors.append( "cannot construct a workload event without the container instance. " "Please pass one.", ) - 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}.", - ) - if event.container not in state.containers: + else: + if not event.name.startswith(normalize_name(event.container.name)): errors.append( - f"cannot emit {event.name} because container {event.container.name} " - f"is not in the state.", + f"workload event should start with container name. {event.name} does " + f"not start with {event.container.name}.", ) - if not event.container.can_connect: - warnings.append( - "you **can** fire fire pebble-ready while the container cannot connect, " - "but that's most likely not what you want.", + if event.container not in state.containers: + errors.append( + f"cannot emit {event.name} because container {event.container.name} " + f"is not in the state.", + ) + if not event.container.can_connect: + warnings.append( + "you **can** fire fire pebble-ready while the container cannot connect, " + "but that's most likely not what you want.", + ) + names = Counter(exec.command_prefix for exec in event.container.execs) + if dupes := [n for n in names if names[n] > 1]: + errors.append( + f"container {event.container.name} has duplicate command prefixes: {dupes}", ) @@ -585,18 +591,20 @@ def check_containers_consistency( f"container with that name is not present in the state. It's odd, but " f"consistent, if it cannot connect; but it should at least be there.", ) + # - you're processing a Notice event and that notice is not in any of the containers if event.notice and event.notice.id not in all_notices: errors.append( f"the event being processed concerns notice {event.notice!r}, but that " "notice is not in any of the containers present in the state.", ) + # - you're processing a Check event and that check is not in the check's container if ( event.check_info and (evt_container_name, event.check_info.name) not in all_checks ): errors.append( f"the event being processed concerns check {event.check_info.name}, but that " - "check is not the {evt_container_name} container.", + f"check is not in the {evt_container_name} container.", ) # - a container in state.containers is not in meta.containers diff --git a/scenario/context.py b/scenario/context.py index 7998ceb4..0f7ca1e1 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast from ops import CharmBase, EventBase +from ops.testing import ExecArgs from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime @@ -450,6 +451,7 @@ 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.workload_version_history: List[str] = [] self.removed_secret_revisions: List[int] = [] self.emitted_events: List[EventBase] = [] diff --git a/scenario/mocking.py b/scenario/mocking.py index 8452b314..b1a60a7d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -3,7 +3,6 @@ # See LICENSE file for licensing details. import datetime import shutil -from io import StringIO from pathlib import Path from typing import ( TYPE_CHECKING, @@ -14,6 +13,7 @@ Mapping, Optional, Set, + TextIO, Tuple, Union, cast, @@ -33,7 +33,7 @@ _ModelBackend, ) from ops.pebble import Client, ExecError -from ops.testing import _TestingPebbleClient +from ops.testing import ExecArgs, _TestingPebbleClient from scenario.logger import logger as scenario_logger from scenario.state import ( @@ -52,7 +52,7 @@ from scenario.context import Context from scenario.state import Container as ContainerSpec from scenario.state import ( - ExecOutput, + Exec, Relation, Secret, State, @@ -72,26 +72,46 @@ class ActionMissingFromContextError(Exception): class _MockExecProcess: - def __init__(self, command: Tuple[str, ...], change_id: int, out: "ExecOutput"): - self._command = command + def __init__( + self, + change_id: int, + args: ExecArgs, + return_code: int, + stdin: Optional[TextIO], + stdout: Optional[TextIO], + stderr: Optional[TextIO], + ): self._change_id = change_id - self._out = out + self._args = args + self._return_code = return_code self._waited = False - self.stdout = StringIO(self._out.stdout) - self.stderr = StringIO(self._out.stderr) + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + + def __del__(self): + if not self._waited: + self._close_stdin() + + def _close_stdin(self): + if self._args.stdin is None and self.stdin is not None: + self.stdin.seek(0) + self._args.stdin = self.stdin.read() def wait(self): + self._close_stdin() self._waited = True - exit_code = self._out.return_code - if exit_code != 0: - raise ExecError(list(self._command), exit_code, None, None) + if self._return_code != 0: + raise ExecError(list(self._args.command), self._return_code, None, None) def wait_output(self): - out = self._out - exit_code = out.return_code - if exit_code != 0: - raise ExecError(list(self._command), exit_code, None, None) - return out.stdout, out.stderr + self._close_stdin() + self._waited = True + stdout = self.stdout.read() if self.stdout is not None else None + stderr = self.stderr.read() if self.stderr is not None else None + if self._return_code != 0: + raise ExecError(list(self._args.command), self._return_code, stdout, stderr) + return stdout, stderr def send_signal(self, sig: Union[int, str]): # noqa: U100 raise NotImplementedError() @@ -165,6 +185,8 @@ def get_pebble(self, socket_path: str) -> "Client": state=self._state, event=self._event, charm_spec=self._charm_spec, + context=self._context, + container_name=container_name, ) def _get_relation_by_id( @@ -705,11 +727,15 @@ def __init__( state: "State", event: "_Event", charm_spec: "_CharmSpec", + context: "Context", + container_name: str, ): self._state = state self.socket_path = socket_path self._event = event self._charm_spec = charm_spec + self._context = context + self._container_name = container_name # wipe just in case if container_root.exists(): @@ -762,21 +788,100 @@ def _layers(self) -> Dict[str, pebble.Layer]: @property def _service_status(self) -> Dict[str, pebble.ServiceStatus]: - return self._container.service_status + return self._container.service_statuses + + # Based on a method of the same name from ops.testing. + def _find_exec_handler(self, command) -> Optional["Exec"]: + handlers = {exec.command_prefix: exec for exec in self._container.execs} + # Start with the full command and, each loop iteration, drop the last + # element, until it matches one of the command prefixes in the execs. + # This includes matching against the empty list, which will match any + # command, if there is not a more specific match. + for prefix_len in reversed(range(len(command) + 1)): + command_prefix = tuple(command[:prefix_len]) + if command_prefix in handlers: + return handlers[command_prefix] + # None of the command prefixes in the execs matched the command, no + # matter how much of it was used, so we have failed to find a handler. + return None - def exec(self, *args, **kwargs): # noqa: U100 type: ignore - cmd = tuple(args[0]) - out = self._container.exec_mock.get(cmd) - if not out: - raise RuntimeError( - f"mock for cmd {cmd} not found. Please pass to the Container " - f"{self._container.name} a scenario.ExecOutput mock for the " - f"command your charm is attempting to run, or patch " - f"out whatever leads to the call.", + def exec( + self, + command: List[str], + *, + environment: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + timeout: Optional[float] = None, + user_id: Optional[int] = None, + user: Optional[str] = None, + group_id: Optional[int] = None, + group: Optional[str] = None, + stdin: Optional[Union[str, bytes, TextIO]] = None, + stdout: Optional[TextIO] = None, + stderr: Optional[TextIO] = None, + encoding: Optional[str] = "utf-8", + combine_stderr: bool = False, + **kwargs, + ): + handler = self._find_exec_handler(command) + if not handler: + raise ExecError( + command, + 127, + "", + f"mock for cmd {command} not found. Please patch out whatever " + f"leads to the call, or pass to the Container {self._container.name} " + f"a scenario.Exec mock for the command your charm is attempting " + f"to run, such as " + f"'Container(..., execs={{scenario.Exec({list(command)}, ...)}})'", ) - change_id = out._run() - return _MockExecProcess(change_id=change_id, command=cmd, out=out) + if stdin is None: + proc_stdin = self._transform_exec_handler_output("", encoding) + else: + proc_stdin = None + stdin = stdin.read() if hasattr(stdin, "read") else stdin # type: ignore + if stdout is None: + proc_stdout = self._transform_exec_handler_output(handler.stdout, encoding) + else: + proc_stdout = None + stdout.write(handler.stdout) + if stderr is None: + proc_stderr = self._transform_exec_handler_output(handler.stderr, encoding) + else: + proc_stderr = None + stderr.write(handler.stderr) + + args = ExecArgs( + command=command, + environment=environment or {}, + working_dir=working_dir, + timeout=timeout, + user_id=user_id, + user=user, + group_id=group_id, + group=group, + stdin=stdin, # type:ignore # If None, will be replaced by proc_stdin.read() later. + encoding=encoding, + combine_stderr=combine_stderr, + ) + try: + self._context.exec_history[self._container_name].append(args) + except KeyError: + self._context.exec_history[self._container_name] = [args] + + change_id = handler._run() + return cast( + pebble.ExecProcess[Any], + _MockExecProcess( + change_id=change_id, + args=args, + return_code=handler.return_code, + stdin=proc_stdin, + stdout=proc_stdout, + stderr=proc_stderr, + ), + ) def _check_connection(self): if not self._container.can_connect: diff --git a/scenario/state.py b/scenario/state.py index 45cb6ea8..7f1e39e9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -27,6 +27,7 @@ List, Literal, Optional, + Sequence, Set, Tuple, Type, @@ -675,26 +676,42 @@ def _generate_new_change_id(): @dataclasses.dataclass(frozen=True) -class ExecOutput(_max_posargs(0)): +class Exec(_max_posargs(1)): """Mock data for simulated :meth:`ops.Container.exec` calls.""" + command_prefix: Sequence[str] return_code: int = 0 - """The return code of the process (0 is success).""" + """The return code of the process. + + Use 0 to mock the process ending successfully, and other values for failure. + """ stdout: str = "" - """Any content written to stdout by the process.""" + """Any content written to stdout by the process. + + Provide content that the real process would write to stdout, which can be + read by the charm. + """ stderr: str = "" - """Any content written to stderr by the process.""" + """Any content written to stderr by the process. + + Provide content that the real process would write to stderr, which can be + read by the charm. + """ # change ID: used internally to keep track of mocked processes _change_id: int = dataclasses.field(default_factory=_generate_new_change_id) + def __post_init__(self): + # The command prefix can be any sequence type, and a list is tidier to + # write when there's only one string. However, this object needs to be + # hashable, so can't contain a list. We 'freeze' the sequence to a tuple + # to support that. + object.__setattr__(self, "command_prefix", tuple(self.command_prefix)) + def _run(self) -> int: return self._change_id -_ExecMock = Dict[Tuple[str, ...], ExecOutput] - - @dataclasses.dataclass(frozen=True) class Mount(_max_posargs(0)): """Maps local files to a :class:`Container` filesystem.""" @@ -835,7 +852,7 @@ class Container(_max_posargs(1)): layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) """All :class:`ops.pebble.Layer` definitions that have already been added to the container.""" - service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field( + service_statuses: Dict[str, pebble.ServiceStatus] = dataclasses.field( default_factory=dict, ) """The current status of each Pebble service running in the container.""" @@ -871,20 +888,23 @@ class Container(_max_posargs(1)): } """ - exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + execs: Iterable[Exec] = frozenset() """Simulate executing commands in the container. - Specify each command the charm might run in the container and a :class:`ExecOutput` + Specify each command the charm might run in the container and an :class:`Exec` containing its return code and any stdout/stderr. For example:: container = scenario.Container( name='foo', - exec_mock={ - ('whoami', ): scenario.ExecOutput(return_code=0, stdout='ubuntu') - ('dig', '+short', 'canonical.com'): - scenario.ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21') + execs={ + scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'), + scenario.Exec( + ['dig', '+short', 'canonical.com'], + return_code=0, + stdout='185.125.190.20\\n185.125.190.21', + ), } ) """ @@ -896,6 +916,11 @@ class Container(_max_posargs(1)): def __hash__(self) -> int: return hash(self.name) + def __post_init__(self): + if not isinstance(self.execs, frozenset): + # Allow passing a regular set (or other iterable) of Execs. + object.__setattr__(self, "execs", frozenset(self.execs)) + def _render_services(self): # copied over from ops.testing._TestingPebbleClient._render_services() services = {} # type: Dict[str, pebble.Service] @@ -936,7 +961,7 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: # in pebble, it just returns "nothing matched" if there are 0 matches, # but it ignores services it doesn't recognize continue - status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE) + status = self.service_statuses.get(name, pebble.ServiceStatus.INACTIVE) if service.startup == "": startup = pebble.ServiceStartup.DISABLED else: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 3db6f8e8..7e717c96 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -12,6 +12,7 @@ CloudCredential, CloudSpec, Container, + Exec, Model, Network, Notice, @@ -23,7 +24,6 @@ Storage, StoredState, SubordinateRelation, - _Action, _CharmSpec, _Event, ) @@ -181,6 +181,18 @@ def test_evt_bad_container_name(): ) +def test_duplicate_execs_in_container(): + container = Container( + "foo", + execs={Exec(["ls", "-l"], return_code=0), Exec(["ls", "-l"], return_code=1)}, + ) + assert_inconsistent( + State(containers=[container]), + _Event("foo-pebble-ready", container=container), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + + @pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) def test_evt_bad_relation_name(suffix): assert_inconsistent( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index dec93c4d..621e4054 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,5 +1,6 @@ import dataclasses import datetime +import io import tempfile from pathlib import Path @@ -10,7 +11,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import CheckInfo, Container, ExecOutput, Mount, Notice, State +from scenario.state import CheckInfo, Container, Exec, Mount, Notice, State from tests.helpers import jsonpatch_delta, trigger @@ -193,7 +194,7 @@ def callback(self: CharmBase): container = self.unit.get_container("foo") proc = container.exec([cmd]) proc.wait() - assert proc.stdout.read() == "hello pebble" + assert proc.stdout.read() == out trigger( State( @@ -201,7 +202,7 @@ def callback(self: CharmBase): Container( name="foo", can_connect=True, - exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, + execs={Exec([cmd], stdout=out)}, ) } ), @@ -212,6 +213,32 @@ def callback(self: CharmBase): ) +@pytest.mark.parametrize( + "stdin,write", + ( + [None, "hello world!"], + ["hello world!", None], + [io.StringIO("hello world!"), None], + ), +) +def test_exec_history_stdin(stdin, write): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + self.framework.observe(self.on.foo_pebble_ready, self._on_ready) + + def _on_ready(self, _): + proc = self.unit.get_container("foo").exec(["ls"], stdin=stdin) + if write: + proc.stdin.write(write) + proc.wait() + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + container = Container(name="foo", can_connect=True, execs={Exec([])}) + ctx.run(ctx.on.pebble_ready(container=container), State(containers={container})) + assert ctx.exec_history[container.name][0].stdin == "hello world!" + + def test_pebble_ready(charm_cls): def callback(self: CharmBase): foo = self.unit.get_container("foo") @@ -279,7 +306,7 @@ def _on_ready(self, event): } ) }, - service_status={ + service_statuses={ "fooserv": pebble.ServiceStatus.ACTIVE, # todo: should we disallow setting status for services that aren't known YET? "barserv": starting_service_status, @@ -312,7 +339,7 @@ def test_exec_wait_error(charm_cls): Container( name="foo", can_connect=True, - exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, + execs={Exec(["foo"], stdout="hello pebble", return_code=1)}, ) } ) @@ -321,20 +348,19 @@ def test_exec_wait_error(charm_cls): with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) - with pytest.raises(ExecError): - proc.wait() - assert proc.stdout.read() == "hello pebble" + with pytest.raises(ExecError) as exc_info: + proc.wait_output() + assert exc_info.value.stdout == "hello pebble" -def test_exec_wait_output(charm_cls): +@pytest.mark.parametrize("command", (["foo"], ["foo", "bar"], ["foo", "bar", "baz"])) +def test_exec_wait_output(charm_cls, command): state = State( containers={ Container( name="foo", can_connect=True, - exec_mock={ - ("foo",): ExecOutput(stdout="hello pebble", stderr="oepsie") - }, + execs={Exec(["foo"], stdout="hello pebble", stderr="oepsie")}, ) } ) @@ -342,10 +368,11 @@ def test_exec_wait_output(charm_cls): ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") - proc = container.exec(["foo"]) + proc = container.exec(command) out, err = proc.wait_output() assert out == "hello pebble" assert err == "oepsie" + assert ctx.exec_history[container.name][0].command == command def test_exec_wait_output_error(charm_cls): @@ -354,7 +381,7 @@ def test_exec_wait_output_error(charm_cls): Container( name="foo", can_connect=True, - exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, + execs={Exec(["foo"], stdout="hello pebble", return_code=1)}, ) } ) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 325cda66..d6e3aa5c 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -279,9 +279,9 @@ def test_container_default_values(): assert container.name == name assert container.can_connect is False assert container.layers == {} - assert container.service_status == {} + assert container.service_statuses == {} assert container.mounts == {} - assert container.exec_mock == {} + assert container.execs == frozenset() assert container.layers == {} assert container._base_plan == {} From 33cb2e3931fa15f5da0f52ee2123b9960d9d5b3a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 29 Aug 2024 19:09:28 +1200 Subject: [PATCH 29/35] chore!: adjust privacy (#176) A collection of small changes that generally fall into "remove x from the public API": * Remove the `with_can_connect`, `with_leadership`, and `with_unit_status` convenience methods from `State`. * Makes `next_relation_id`, `next_action_id`, `next_storage_index`, and `next_notice_id` private. * Removes `Context.output_state`. * Makes all the *_SUFFIX constants private. * Makes all the *_EVENTS constants private, except `META_EVENTS`, which is removed. * Makes `capture_events` private (and consolidates capture_events.py into runtime.py). * Makes both `hook_tool_output_fmt` methods private. * Makes `normalize_name` private. * Moves all of the Scenario error exception classes (the ones that no-one should be catching) to a scenario.errors namespace/module. * Renames the consistency checker module to be private. * Makes `DEFAULT_JUJU_VERSION` and `DEFAULT_JUJU_DATABAG` private. * Adds various classes/types to the top-level scenario namespace for use in type annotations: * Removes `AnyRelation` in favour of using `RelationBase` * Removes `PathLike` in favour of `str|Path`. Fixes #175. --- README.md | 73 +-------- docs/custom_conf.py | 2 - docs/index.rst | 11 -- pyproject.toml | 5 - scenario/__init__.py | 58 ++++--- ...ncy_checker.py => _consistency_checker.py} | 16 +- scenario/capture_events.py | 101 ------------ scenario/context.py | 61 +++---- scenario/errors.py | 56 +++++++ scenario/mocking.py | 37 ++--- scenario/ops_main_mock.py | 21 +-- scenario/runtime.py | 136 +++++++++++++--- scenario/state.py | 149 +++++++----------- tests/helpers.py | 8 +- tests/test_consistency_checker.py | 22 +-- tests/test_context.py | 4 +- tests/test_e2e/test_actions.py | 4 +- tests/test_e2e/test_relations.py | 32 ++-- tests/test_e2e/test_state.py | 6 +- tests/test_emitted_events_util.py | 11 +- 20 files changed, 343 insertions(+), 470 deletions(-) rename scenario/{consistency_checker.py => _consistency_checker.py} (97%) delete mode 100644 scenario/capture_events.py create mode 100644 scenario/errors.py diff --git a/README.md b/README.md index bab3ab89..7204fb08 100644 --- a/README.md +++ b/README.md @@ -263,51 +263,6 @@ def test_emitted_full(): ] ``` -### Low-level access: using directly `capture_events` - -If you need more control over what events are captured (or you're not into pytest), you can use directly the context -manager that powers the `emitted_events` fixture: `scenario.capture_events`. -This context manager allows you to intercept any events emitted by the framework. - -Usage: - -```python -import scenario.capture_events - -with scenario.capture_events.capture_events() as emitted: - ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) - state_out = ctx.run( - ctx.on.update_status(), - scenario.State(deferred=[ctx.on.start().deferred(SimpleCharm._on_start)]) - ) - -# deferred events get reemitted first -assert isinstance(emitted[0], ops.StartEvent) -# the main Juju event gets emitted next -assert isinstance(emitted[1], ops.UpdateStatusEvent) -# possibly followed by a tail of all custom events that the main Juju event triggered in turn -# assert isinstance(emitted[2], MyFooEvent) -# ... -``` - -You can filter events by type like so: - -```python -import scenario.capture_events - -with scenario.capture_events.capture_events(ops.StartEvent, ops.RelationEvent) as emitted: - # capture all `start` and `*-relation-*` events. - pass -``` - -Configuration: - -- Passing no event types, like: `capture_events()`, is equivalent to `capture_events(ops.EventBase)`. -- By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if - they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. -- By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by - passing: `capture_events(include_deferred=False)`. - ## Relations You can write scenario tests to verify the shape of relation data: @@ -439,32 +394,6 @@ joined_event = ctx.on.relation_joined(relation=relation) The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario needs to set up the process that will run `ops.main` with the right environment variables. -### Working with relation IDs - -Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `id`. -To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`. - -```python -import scenario.state - -next_id = scenario.state.next_relation_id(update=False) -rel = scenario.Relation('foo') -assert rel.id == next_id -``` - -This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: - -```python -import dataclasses -import scenario.state - -rel = scenario.Relation('foo') -rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, id=scenario.state.next_relation_id()) -assert rel2.id == rel.id + 1 -``` - -If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. - ### Additional event parameters All relation events have some additional metadata that does not belong in the Relation object, such as, for a @@ -1231,7 +1160,7 @@ therefore, so far as we're concerned, that can't happen, and therefore we help y are consistent and raise an exception if that isn't so. That happens automatically behind the scenes whenever you trigger an event; -`scenario.consistency_checker.check_consistency` is called and verifies that the scenario makes sense. +`scenario._consistency_checker.check_consistency` is called and verifies that the scenario makes sense. ## Caveats: diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 10deb009..70bf3e10 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -306,10 +306,8 @@ 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', '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..272af959 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,17 +17,6 @@ scenario.Context .. automodule:: scenario.context -scenario.consistency_checker -============================ - -.. automodule:: scenario.consistency_checker - - -scenario.capture_events -======================= - -.. automodule:: scenario.capture_events - Indices ======= diff --git a/pyproject.toml b/pyproject.toml index 99f1be05..b1f030d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,11 +96,6 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" -[tool.pyright] -ignore = [ - "scenario/sequences.py", - "scenario/capture_events.py" -] [tool.isort] profile = "black" diff --git a/scenario/__init__.py b/scenario/__init__.py index 1b9416f8..3439daa1 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + from scenario.context import Context, Manager from scenario.state import ( ActionFailed, ActiveStatus, Address, + AnyJson, BindAddress, BlockedStatus, + CharmType, CheckInfo, CloudCredential, CloudSpec, @@ -16,6 +19,7 @@ ErrorStatus, Exec, ICMPPort, + JujuLogLine, MaintenanceStatus, Model, Mount, @@ -23,7 +27,10 @@ Notice, PeerRelation, Port, + RawDataBagContents, + RawSecretRevisionContents, Relation, + RelationBase, Resource, Secret, State, @@ -33,43 +40,52 @@ SubordinateRelation, TCPPort, UDPPort, + UnitID, UnknownStatus, WaitingStatus, ) __all__ = [ "ActionFailed", + "ActiveStatus", + "Address", + "AnyJson", + "BindAddress", + "BlockedStatus", + "CharmType", "CheckInfo", "CloudCredential", "CloudSpec", + "Container", "Context", - "StateValidationError", - "Secret", - "Relation", - "SubordinateRelation", - "PeerRelation", - "Model", + "DeferredEvent", + "ErrorStatus", "Exec", + "ICMPPort", + "JujuLogLine", + "MaintenanceStatus", + "Manager", + "Model", "Mount", - "Container", - "Notice", - "Address", - "BindAddress", "Network", + "Notice", + "PeerRelation", "Port", - "ICMPPort", - "TCPPort", - "UDPPort", + "RawDataBagContents", + "RawSecretRevisionContents", + "Relation", + "RelationBase", "Resource", + "Secret", + "State", + "StateValidationError", "Storage", "StoredState", - "State", - "DeferredEvent", - "ErrorStatus", - "BlockedStatus", - "WaitingStatus", - "MaintenanceStatus", - "ActiveStatus", + "SubordinateRelation", + "TCPPort", + "UDPPort", + "UnitID", "UnknownStatus", - "Manager", + "WaitingStatus", + "deferred", ] diff --git a/scenario/consistency_checker.py b/scenario/_consistency_checker.py similarity index 97% rename from scenario/consistency_checker.py rename to scenario/_consistency_checker.py index c2205540..68fd3c24 100644 --- a/scenario/consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -9,14 +9,14 @@ from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union -from scenario.runtime import InconsistentScenarioError +from scenario.errors import InconsistentScenarioError from scenario.runtime import logger as scenario_logger from scenario.state import ( PeerRelation, SubordinateRelation, _Action, _CharmSpec, - normalize_name, + _normalise_name, ) if TYPE_CHECKING: # pragma: no cover @@ -170,7 +170,7 @@ def _check_relation_event( "Please pass one.", ) else: - if not event.name.startswith(normalize_name(event.relation.endpoint)): + if not event.name.startswith(_normalise_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}.", @@ -194,7 +194,7 @@ def _check_workload_event( "Please pass one.", ) else: - if not event.name.startswith(normalize_name(event.container.name)): + if not event.name.startswith(_normalise_name(event.container.name)): errors.append( f"workload event should start with container name. {event.name} does " f"not start with {event.container.name}.", @@ -231,7 +231,7 @@ def _check_action_event( ) return - elif not event.name.startswith(normalize_name(action.name)): + elif not event.name.startswith(_normalise_name(action.name)): errors.append( f"action event should start with action name. {event.name} does " f"not start with {action.name}.", @@ -261,7 +261,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(_normalise_name(storage.name)): errors.append( f"storage event should start with storage name. {event.name} does " f"not start with {storage.name}.", @@ -566,8 +566,8 @@ def check_containers_consistency( # 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(_normalise_name, meta.get("containers", {}))) + state_containers = [_normalise_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 diff --git a/scenario/capture_events.py b/scenario/capture_events.py deleted file mode 100644 index 3b094797..00000000 --- a/scenario/capture_events.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import typing -from contextlib import contextmanager -from typing import Type, TypeVar - -from ops import CollectStatusEvent -from ops.framework import ( - CommitEvent, - EventBase, - Framework, - Handle, - NoTypeError, - PreCommitEvent, -) - -_T = TypeVar("_T", bound=EventBase) - - -@contextmanager -def capture_events( - *types: Type[EventBase], - 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. - - 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' - """ - allowed_types = types or (EventBase,) - - captured = [] - _real_emit = Framework._emit - _real_reemit = Framework.reemit - - def _wrapped_emit(self, evt): - if not include_framework and isinstance( - evt, - (PreCommitEvent, CommitEvent, CollectStatusEvent), - ): - return _real_emit(self, evt) - - if isinstance(evt, allowed_types): - # dump/undump the event to ensure any custom attributes are (re)set by restore() - evt.restore(evt.snapshot()) - captured.append(evt) - - return _real_emit(self, evt) - - def _wrapped_reemit(self): - # Framework calls reemit() before emitting the main juju event. We intercept that call - # and capture all events in storage. - - if not include_deferred: - return _real_reemit(self) - - # load all notices from storage as events. - for event_path, _, _ in self._storage.notices(): - event_handle = Handle.from_path(event_path) - try: - event = self.load_snapshot(event_handle) - except NoTypeError: - continue - event = typing.cast(EventBase, event) - event.deferred = False - self._forget(event) # prevent tracking conflicts - - if not include_framework and isinstance( - event, - (PreCommitEvent, CommitEvent), - ): - continue - - if isinstance(event, allowed_types): - captured.append(event) - - return _real_reemit(self) - - Framework._emit = _wrapped_emit # type: ignore - Framework.reemit = _wrapped_reemit # type: ignore - - yield captured - - Framework._emit = _real_emit # type: ignore - Framework.reemit = _real_reemit # type: ignore diff --git a/scenario/context.py b/scenario/context.py index 0f7ca1e1..67759789 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -9,6 +9,7 @@ from ops import CharmBase, EventBase from ops.testing import ExecArgs +from scenario.errors import AlreadyEmittedError, ContextSetupError from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( @@ -28,29 +29,11 @@ from ops.testing import CharmType from scenario.ops_main_mock import Ops - from scenario.state import AnyJson, AnyRelation, JujuLogLine, State, _EntityStatus - - PathLike = Union[str, Path] + from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus logger = scenario_logger.getChild("runtime") -DEFAULT_JUJU_VERSION = "3.4" - - -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""" - - -class ContextSetupError(RuntimeError): - """Raised by Context when setup fails.""" - - -class AlreadyEmittedError(RuntimeError): - """Raised when ``run()`` is called more than once.""" +_DEFAULT_JUJU_VERSION = "3.5" class Manager: @@ -218,11 +201,11 @@ def collect_unit_status(): return _Event("collect_unit_status") @staticmethod - def relation_created(relation: "AnyRelation"): + def relation_created(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_created", relation=relation) @staticmethod - def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_joined", relation=relation, @@ -230,7 +213,11 @@ def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = Non ) @staticmethod - def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + def relation_changed( + relation: "RelationBase", + *, + remote_unit: Optional[int] = None, + ): return _Event( f"{relation.endpoint}_relation_changed", relation=relation, @@ -239,7 +226,7 @@ def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = No @staticmethod def relation_departed( - relation: "AnyRelation", + relation: "RelationBase", *, remote_unit: Optional[int] = None, departing_unit: Optional[int] = None, @@ -252,7 +239,7 @@ def relation_departed( ) @staticmethod - def relation_broken(relation: "AnyRelation"): + def relation_broken(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_broken", relation=relation) @staticmethod @@ -384,8 +371,8 @@ def __init__( *, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, - juju_version: str = DEFAULT_JUJU_VERSION, + charm_root: Optional[Union[str, Path]] = None, + juju_version: str = _DEFAULT_JUJU_VERSION, capture_deferred_events: bool = False, capture_framework_events: bool = False, app_name: Optional[str] = None, @@ -471,19 +458,6 @@ def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" self._output_state = output_state - @property - def output_state(self) -> "State": - """The output state obtained by running an event on this context. - - Raises: - RuntimeError: if this ``Context`` hasn't been :meth:`run` yet. - """ - if not self._output_state: - raise RuntimeError( - "No output state available. ``.run()`` this Context first.", - ) - return self._output_state - def _get_container_root(self, container_name: str): """Get the path to a tempdir where this container's simulated root will live.""" return Path(self._tmp.name) / "containers" / container_name @@ -538,10 +512,13 @@ def run(self, event: "_Event", state: "State") -> "State": self._action_failure_message = None with self._run(event=event, state=state) as ops: ops.emit() + # We know that the output state will have been set by this point, + # so let the type checkers know that too. + assert self._output_state is not None if event.action: if self._action_failure_message is not None: - raise ActionFailed(self._action_failure_message, self.output_state) - return self.output_state + raise ActionFailed(self._action_failure_message, self._output_state) + return self._output_state @contextmanager def _run(self, event: "_Event", state: "State"): diff --git a/scenario/errors.py b/scenario/errors.py new file mode 100644 index 00000000..56a01d12 --- /dev/null +++ b/scenario/errors.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Exceptions raised by the framework. + +Note that these exceptions are not meant to be caught by charm authors. They are +used by the framework to signal errors or inconsistencies in the charm tests +themselves. +""" + + +class ContextSetupError(RuntimeError): + """Raised by Context when setup fails.""" + + +class AlreadyEmittedError(RuntimeError): + """Raised when ``run()`` is called more than once.""" + + +class ScenarioRuntimeError(RuntimeError): + """Base class for exceptions raised by the runtime module.""" + + +class UncaughtCharmError(ScenarioRuntimeError): + """Error raised if the charm raises while handling the event being dispatched.""" + + +class InconsistentScenarioError(ScenarioRuntimeError): + """Error raised when the combination of state and event is inconsistent.""" + + +class StateValidationError(RuntimeError): + """Raised when individual parts of the State are inconsistent.""" + + # as opposed to InconsistentScenario error where the **combination** of + # several parts of the State are. + + +class MetadataNotFoundError(RuntimeError): + """Raised when Scenario can't find a metadata file in the provided charm root.""" + + +class ActionMissingFromContextError(Exception): + """Raised when the user attempts to invoke action hook tools outside an action context.""" + + # This is not an ops error: in ops, you'd have to go exceptionally out of + # your way to trigger this flow. + + +class NoObserverError(RuntimeError): + """Error raised when the event being dispatched has no registered observers.""" + + +class BadOwnerPath(RuntimeError): + """Error raised when the owner path does not lead to a valid ObjectEvents instance.""" diff --git a/scenario/mocking.py b/scenario/mocking.py index b1a60a7d..f5207a37 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -35,13 +35,17 @@ from ops.pebble import Client, ExecError from ops.testing import ExecArgs, _TestingPebbleClient +from scenario.errors import ActionMissingFromContextError from scenario.logger import logger as scenario_logger from scenario.state import ( JujuLogLine, Mount, Network, PeerRelation, + Relation, + RelationBase, Storage, + SubordinateRelation, _EntityStatus, _port_cls_by_protocol, _RawPortProtocolLiteral, @@ -51,26 +55,11 @@ if TYPE_CHECKING: # pragma: no cover from scenario.context import Context from scenario.state import Container as ContainerSpec - from scenario.state import ( - Exec, - Relation, - Secret, - State, - SubordinateRelation, - _CharmSpec, - _Event, - ) + from scenario.state import Exec, Secret, State, _CharmSpec, _Event logger = scenario_logger.getChild("mocking") -class ActionMissingFromContextError(Exception): - """Raised when the user attempts to invoke action hook tools outside an action context.""" - - # This is not an ops error: in ops, you'd have to go exceptionally out of your way to trigger - # this flow. - - class _MockExecProcess: def __init__( self, @@ -189,10 +178,7 @@ def get_pebble(self, socket_path: str) -> "Client": container_name=container_name, ) - def _get_relation_by_id( - self, - rel_id, - ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: + def _get_relation_by_id(self, rel_id) -> "RelationBase": try: return self._state.get_relation(rel_id) except ValueError: @@ -254,7 +240,10 @@ def relation_get(self, relation_id: int, member_name: str, is_app: bool): elif is_app: if isinstance(relation, PeerRelation): return relation.local_app_data - return relation.remote_app_data + elif isinstance(relation, (Relation, SubordinateRelation)): + return relation.remote_app_data + else: + raise TypeError("relation_get: unknown relation type") elif member_name == self.unit_name: return relation.local_unit_data @@ -337,7 +326,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): @@ -570,8 +559,10 @@ def relation_remote_app_name( if isinstance(relation, PeerRelation): return self.app_name - else: + elif isinstance(relation, (Relation, SubordinateRelation)): return relation.remote_app_name + else: + raise TypeError("relation_remote_app_name: unknown relation type") def action_set(self, results: Dict[str, Any]): if not self._event.action: diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index b9bcbb8f..cc7391cc 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -19,6 +19,8 @@ from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args from ops.main import logger as ops_logger +from scenario.errors import BadOwnerPath, NoObserverError + if TYPE_CHECKING: # pragma: no cover from scenario.context import Context from scenario.state import State, _CharmSpec, _Event @@ -26,25 +28,6 @@ # pyright: reportPrivateUsage=false -class NoObserverError(RuntimeError): - """Error raised when the event being dispatched has no registered observers.""" - - -class BadOwnerPath(RuntimeError): - """Error raised when the owner path does not lead to a valid ObjectEvents instance.""" - - -# TODO: Use ops.jujucontext's _JujuContext.charm_dir. -def _get_charm_dir(): - charm_dir = os.environ.get("JUJU_CHARM_DIR") - if charm_dir is None: - # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. - charm_dir = pathlib.Path(f"{__file__}/../../..").resolve() - else: - charm_dir = pathlib.Path(charm_dir).resolve() - return charm_dir - - def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: """Walk path on root to an ObjectEvents instance.""" obj = root diff --git a/scenario/runtime.py b/scenario/runtime.py index e853c682..754829c0 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,17 +10,32 @@ import typing from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, Union +from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, TypeVar, Union import yaml -from ops import pebble -from ops.framework import _event_regex +from ops import CollectStatusEvent, pebble +from ops.framework import ( + CommitEvent, + EventBase, + Framework, + Handle, + NoTypeError, + PreCommitEvent, + _event_regex, +) from ops.storage import NoSnapshotError, SQLiteStorage -from scenario.capture_events import capture_events +from scenario.errors import UncaughtCharmError from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.state import ActionFailed, DeferredEvent, PeerRelation, StoredState +from scenario.state import ( + ActionFailed, + DeferredEvent, + PeerRelation, + Relation, + StoredState, + SubordinateRelation, +) if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -28,8 +43,6 @@ from scenario.context import Context from scenario.state import State, _CharmSpec, _Event - PathLike = Union[str, Path] - logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( r"((?P.*)\/)?(?P<_data_type_name>\D+)\[(?P.*)\]", @@ -39,18 +52,6 @@ RUNTIME_MODULE = Path(__file__).parent -class ScenarioRuntimeError(RuntimeError): - """Base class for exceptions raised by scenario.runtime.""" - - -class UncaughtCharmError(ScenarioRuntimeError): - """Error raised if the charm raises while handling the event being dispatched.""" - - -class InconsistentScenarioError(ScenarioRuntimeError): - """Error raised when the combination of state and event is inconsistent.""" - - class UnitStateDB: """Represents the unit-state.db.""" @@ -156,7 +157,7 @@ class Runtime: def __init__( self, charm_spec: "_CharmSpec", - charm_root: Optional["PathLike"] = None, + charm_root: Optional[Union[str, Path]] = None, juju_version: str = "3.0.0", app_name: Optional[str] = None, unit_id: Optional[int] = 0, @@ -206,8 +207,10 @@ def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): if event._is_relation_event and (relation := event.relation): if isinstance(relation, PeerRelation): remote_app_name = self._app_name - else: + elif isinstance(relation, (Relation, SubordinateRelation)): remote_app_name = relation.remote_app_name + else: + raise ValueError(f"Unknown relation type: {relation}") env.update( { "JUJU_RELATION": relation.endpoint, @@ -398,8 +401,8 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): def _exec_ctx(self, ctx: "Context"): """python 3.8 compatibility shim""" with self._virtual_charm_root() as temporary_charm_root: - # todo allow customizing capture_events - with capture_events( + # TODO: allow customising capture_events + with _capture_events( include_deferred=ctx.capture_deferred_events, include_framework=ctx.capture_framework_events, ) as captured: @@ -423,7 +426,7 @@ def exec( # todo consider forking out a real subprocess and do the mocking by # mocking hook tool executables - from scenario.consistency_checker import check_consistency # avoid cycles + from scenario._consistency_checker import check_consistency # avoid cycles check_consistency(state, event, self._charm_spec, self._juju_version) @@ -485,3 +488,88 @@ def exec( context.emitted_events.extend(captured) logger.info("event dispatched. done.") context._set_output_state(output_state) + + +_T = TypeVar("_T", bound=EventBase) + + +@contextmanager +def _capture_events( + *types: Type[EventBase], + 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. + + 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' + """ + allowed_types = types or (EventBase,) + + captured = [] + _real_emit = Framework._emit + _real_reemit = Framework.reemit + + def _wrapped_emit(self, evt): + if not include_framework and isinstance( + evt, + (PreCommitEvent, CommitEvent, CollectStatusEvent), + ): + return _real_emit(self, evt) + + if isinstance(evt, allowed_types): + # dump/undump the event to ensure any custom attributes are (re)set by restore() + evt.restore(evt.snapshot()) + captured.append(evt) + + return _real_emit(self, evt) + + def _wrapped_reemit(self): + # Framework calls reemit() before emitting the main juju event. We intercept that call + # and capture all events in storage. + + if not include_deferred: + return _real_reemit(self) + + # load all notices from storage as events. + for event_path, _, _ in self._storage.notices(): + event_handle = Handle.from_path(event_path) + try: + event = self.load_snapshot(event_handle) + except NoTypeError: + continue + event = typing.cast(EventBase, event) + event.deferred = False + self._forget(event) # prevent tracking conflicts + + if not include_framework and isinstance( + event, + (PreCommitEvent, CommitEvent), + ): + continue + + if isinstance(event, allowed_types): + captured.append(event) + + return _real_reemit(self) + + Framework._emit = _wrapped_emit # type: ignore + Framework.reemit = _wrapped_reemit # type: ignore + + yield captured + + Framework._emit = _real_emit # type: ignore + Framework.reemit = _real_reemit # type: ignore diff --git a/scenario/state.py b/scenario/state.py index 7f1e39e9..33d5f280 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -45,6 +45,7 @@ from ops.model import CloudSpec as CloudSpec_Ops from ops.model import SecretRotate, StatusBase +from scenario.errors import MetadataNotFoundError, StateValidationError from scenario.logger import logger as scenario_logger JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) @@ -52,8 +53,6 @@ if TYPE_CHECKING: # pragma: no cover from scenario import Context -PathLike = Union[str, Path] -AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] AnyJson = Union[str, bool, dict, int, float, list] RawSecretRevisionContents = RawDataBagContents = Dict[str, str] UnitID = int @@ -67,9 +66,9 @@ BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" -ACTION_EVENT_SUFFIX = "_action" +_ACTION_EVENT_SUFFIX = "_action" # all builtin events except secret events. They're special because they carry secret metadata. -BUILTIN_EVENTS = { +_BUILTIN_EVENTS = { "start", "stop", "install", @@ -86,53 +85,35 @@ "leader_settings_changed", "collect_metrics", } -FRAMEWORK_EVENTS = { +_FRAMEWORK_EVENTS = { "pre_commit", "commit", "collect_app_status", "collect_unit_status", } -PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" -PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" -PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed" -PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered" -RELATION_EVENTS_SUFFIX = { +_PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" +_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" +_PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed" +_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered" +_RELATION_EVENTS_SUFFIX = { "_relation_changed", "_relation_broken", "_relation_joined", "_relation_departed", "_relation_created", } -STORAGE_EVENTS_SUFFIX = { +_STORAGE_EVENTS_SUFFIX = { "_storage_detaching", "_storage_attached", } -SECRET_EVENTS = { +_SECRET_EVENTS = { "secret_changed", "secret_remove", "secret_rotate", "secret_expired", } -META_EVENTS = { - "CREATE_ALL_RELATIONS": "_relation_created", - "BREAK_ALL_RELATIONS": "_relation_broken", - "DETACH_ALL_STORAGES": "_storage_detaching", - "ATTACH_ALL_STORAGES": "_storage_attached", -} - - -class StateValidationError(RuntimeError): - """Raised when individual parts of the State are inconsistent.""" - - # as opposed to InconsistentScenario error where the - # **combination** of several parts of the State are. - - -class MetadataNotFoundError(RuntimeError): - """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()`.""" @@ -362,7 +343,7 @@ def _update_metadata( object.__setattr__(self, "rotate", rotate) -def normalize_name(s: str): +def _normalise_name(s: str): """Event names, in Scenario, uniformly use underscores instead of dashes.""" return s.replace("-", "_") @@ -397,7 +378,7 @@ class BindAddress(_max_posargs(1)): interface_name: str = "" mac_address: Optional[str] = None - 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 = { @@ -425,10 +406,12 @@ class Network(_max_posargs(2)): 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, } @@ -437,7 +420,7 @@ def hook_tool_output_fmt(self): _next_relation_id_counter = 1 -def next_relation_id(*, update=True): +def _next_relation_id(*, update=True): global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -454,7 +437,7 @@ class RelationBase(_max_posargs(2)): """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.""" - id: int = dataclasses.field(default_factory=next_relation_id) + id: int = dataclasses.field(default_factory=_next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, if there's trouble, override.""" @@ -462,7 +445,7 @@ class RelationBase(_max_posargs(2)): """This application's databag for this relation.""" local_unit_data: "RawDataBagContents" = dataclasses.field( - default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), + default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) """This unit's databag for this relation.""" @@ -510,8 +493,8 @@ def _validate_databag(self, databag: dict): ) -_DEFAULT_IP = " 192.0.2.0" -DEFAULT_JUJU_DATABAG = { +_DEFAULT_IP = "192.0.2.0" +_DEFAULT_JUJU_DATABAG = { "egress-subnets": _DEFAULT_IP, "ingress-address": _DEFAULT_IP, "private-address": _DEFAULT_IP, @@ -531,7 +514,7 @@ class Relation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) """The current content of the application databag.""" remote_units_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( - default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, # dedup + default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()}, # dedup ) """The current content of the databag for each unit in the relation.""" @@ -565,7 +548,7 @@ def _databags(self): class SubordinateRelation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_unit_data: "RawDataBagContents" = dataclasses.field( - default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), + default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) # app name and ID of the remote unit that *this unit* is attached to. @@ -607,7 +590,7 @@ class PeerRelation(RelationBase): """A relation to share data between units of the charm.""" peers_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( - default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, + default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()}, ) """Current contents of the peer databags.""" # Consistency checks will validate that *this unit*'s ID is not in here. @@ -729,7 +712,7 @@ def _now_utc(): _next_notice_id_counter = 1 -def next_notice_id(*, update=True): +def _next_notice_id(*, update=True): global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -746,7 +729,7 @@ class Notice(_max_posargs(1)): ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``. """ - id: str = dataclasses.field(default_factory=next_notice_id) + id: str = dataclasses.field(default_factory=_next_notice_id) """Unique ID for this notice.""" user_id: Optional[int] = None @@ -1212,7 +1195,7 @@ def __post_init__(self): _next_storage_index_counter = 0 # storage indices start at 0 -def next_storage_index(*, update=True): +def _next_storage_index(*, update=True): """Get the index (used to be called ID) the next Storage to be created will get. Pass update=False if you're only inspecting it. @@ -1231,7 +1214,7 @@ class Storage(_max_posargs(1)): name: str - index: int = dataclasses.field(default_factory=next_storage_index) + index: int = dataclasses.field(default_factory=_next_storage_index) # Every new Storage instance gets a new one, if there's trouble, override. def __eq__(self, other: object) -> bool: @@ -1249,7 +1232,7 @@ class Resource(_max_posargs(0)): """Represents a resource made available to the charm.""" name: str - path: "PathLike" + path: Union[str, Path] @dataclasses.dataclass(frozen=True) @@ -1265,7 +1248,7 @@ class State(_max_posargs(0)): default_factory=dict, ) """The present configuration of this charm.""" - relations: Iterable["AnyRelation"] = dataclasses.field(default_factory=frozenset) + relations: Iterable["RelationBase"] = dataclasses.field(default_factory=frozenset) """All relations that currently exist for this charm.""" networks: Iterable[Network] = dataclasses.field(default_factory=frozenset) """Manual overrides for any relation and extra bindings currently provisioned for this charm. @@ -1394,24 +1377,6 @@ def _update_secrets(self, new_secrets: FrozenSet[Secret]): # bypass frozen dataclass object.__setattr__(self, "secrets", new_secrets) - def with_can_connect(self, container_name: str, can_connect: bool) -> "State": - def replacer(container: Container): - if container.name == container_name: - return dataclasses.replace(container, can_connect=can_connect) - return container - - ctrs = tuple(map(replacer, self.containers)) - return dataclasses.replace(self, containers=ctrs) - - def with_leadership(self, leader: bool) -> "State": - return dataclasses.replace(self, leader=leader) - - def with_unit_status(self, status: StatusBase) -> "State": - return dataclasses.replace( - self, - unit_status=_EntityStatus.from_ops(status), - ) - def get_container(self, container: str, /) -> Container: """Get container from this State, based on its name.""" for state_container in self.containers: @@ -1473,14 +1438,14 @@ def get_storage( f"storage: name={storage}, index={index} not found in the State", ) - def get_relation(self, relation: int, /) -> "AnyRelation": + def get_relation(self, relation: int, /) -> "RelationBase": """Get relation from this State, based on the relation's id.""" for state_relation in self.relations: if state_relation.id == relation: return state_relation raise KeyError(f"relation: id={relation} not found in the State") - def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: + def get_relations(self, endpoint: str) -> Tuple["RelationBase", ...]: """Get all relations on this endpoint from the current state.""" # we rather normalize the endpoint than worry about cursed metadata situations such as: @@ -1488,11 +1453,11 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: # foo-bar: ... # foo_bar: ... - normalized_endpoint = normalize_name(endpoint) + normalized_endpoint = _normalise_name(endpoint) return tuple( r for r in self.relations - if normalize_name(r.endpoint) == normalized_endpoint + if _normalise_name(r.endpoint) == normalized_endpoint ) @@ -1643,7 +1608,7 @@ class _EventPath(str): type: _EventType def __new__(cls, string): - string = normalize_name(string) + string = _normalise_name(string) instance = super().__new__(cls, string) instance.name = name = string.split(".")[-1] @@ -1662,35 +1627,35 @@ def __new__(cls, string): @staticmethod def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: - for suffix in RELATION_EVENTS_SUFFIX: + for suffix in _RELATION_EVENTS_SUFFIX: if s.endswith(suffix): return suffix, _EventType.relation - if s.endswith(ACTION_EVENT_SUFFIX): - return ACTION_EVENT_SUFFIX, _EventType.action + if s.endswith(_ACTION_EVENT_SUFFIX): + return _ACTION_EVENT_SUFFIX, _EventType.action - if s in SECRET_EVENTS: + if s in _SECRET_EVENTS: return s, _EventType.secret - if s in FRAMEWORK_EVENTS: + if s in _FRAMEWORK_EVENTS: return s, _EventType.framework # Whether the event name indicates that this is a storage event. - for suffix in STORAGE_EVENTS_SUFFIX: + for suffix in _STORAGE_EVENTS_SUFFIX: if s.endswith(suffix): return suffix, _EventType.storage # Whether the event name indicates that this is a workload event. - if s.endswith(PEBBLE_READY_EVENT_SUFFIX): - return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload - if s.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): - return PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload - if s.endswith(PEBBLE_CHECK_FAILED_EVENT_SUFFIX): - return PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload - if s.endswith(PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX): - return PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload - - if s in BUILTIN_EVENTS: + if s.endswith(_PEBBLE_READY_EVENT_SUFFIX): + return _PEBBLE_READY_EVENT_SUFFIX, _EventType.workload + if s.endswith(_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): + return _PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload + if s.endswith(_PEBBLE_CHECK_FAILED_EVENT_SUFFIX): + return _PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload + if s.endswith(_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX): + return _PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload + + if s in _BUILTIN_EVENTS: return "", _EventType.builtin return "", _EventType.custom @@ -1711,7 +1676,7 @@ class Event: storage: Optional["Storage"] = None """If this is a storage event, the storage it refers to.""" - relation: Optional["AnyRelation"] = None + relation: Optional["RelationBase"] = None """If this is a relation event, the relation it refers to.""" relation_remote_unit_id: Optional[int] = None relation_departed_unit_id: Optional[int] = None @@ -1860,8 +1825,10 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: # FIXME: relation.unit for peers should point to , but we # don't have access to the local app name in this context. remote_app = "local" - else: + elif isinstance(relation, (Relation, SubordinateRelation)): remote_app = relation.remote_app_name + else: + raise RuntimeError(f"unexpected relation type: {relation!r}") snapshot_data.update( { @@ -1915,7 +1882,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: _next_action_id_counter = 1 -def next_action_id(*, update=True): +def _next_action_id(*, update=True): global _next_action_id_counter cur = _next_action_id_counter if update: @@ -1946,7 +1913,7 @@ def test_backup_action(): params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) """Parameter values passed to the action.""" - id: str = dataclasses.field(default_factory=next_action_id) + id: str = dataclasses.field(default_factory=_next_action_id) """Juju action ID. Every action invocation is automatically assigned a new one. Override in diff --git a/tests/helpers.py b/tests/helpers.py index 82161c79..5ceffa9d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -15,7 +15,7 @@ import jsonpatch -from scenario.context import DEFAULT_JUJU_VERSION, Context +from scenario.context import _DEFAULT_JUJU_VERSION, Context if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -24,8 +24,6 @@ _CT = TypeVar("_CT", bound=Type[CharmType]) - PathLike = Union[str, Path] - logger = logging.getLogger() @@ -38,8 +36,8 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, - juju_version: str = DEFAULT_JUJU_VERSION, + charm_root: Optional[Union[str, Path]] = None, + juju_version: str = _DEFAULT_JUJU_VERSION, ) -> "State": ctx = Context( charm_type=charm_type, diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 7e717c96..e585d10e 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -3,11 +3,11 @@ import pytest from ops.charm import CharmBase -from scenario.consistency_checker import check_consistency +from scenario._consistency_checker import check_consistency from scenario.context import Context -from scenario.runtime import InconsistentScenarioError +from scenario.errors import InconsistentScenarioError from scenario.state import ( - RELATION_EVENTS_SUFFIX, + _RELATION_EVENTS_SUFFIX, CheckInfo, CloudCredential, CloudSpec, @@ -181,19 +181,7 @@ def test_evt_bad_container_name(): ) -def test_duplicate_execs_in_container(): - container = Container( - "foo", - execs={Exec(["ls", "-l"], return_code=0), Exec(["ls", "-l"], return_code=1)}, - ) - assert_inconsistent( - State(containers=[container]), - _Event("foo-pebble-ready", container=container), - _CharmSpec(MyCharm, {"containers": {"foo": {}}}), - ) - - -@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) +@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX) def test_evt_bad_relation_name(suffix): assert_inconsistent( State(), @@ -208,7 +196,7 @@ def test_evt_bad_relation_name(suffix): ) -@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) +@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX) def test_evt_no_relation(suffix): assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) relation = Relation("bar") diff --git a/tests/test_context.py b/tests/test_context.py index 361b4543..0d55ca9e 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -4,7 +4,7 @@ from ops import CharmBase from scenario import Context, State -from scenario.state import _Event, next_action_id +from scenario.state import _Event, _next_action_id class MyCharm(CharmBase): @@ -32,7 +32,7 @@ def test_run(): def test_run_action(): ctx = Context(MyCharm, meta={"name": "foo"}) state = State() - expected_id = next_action_id(update=False) + expected_id = _next_action_id(update=False) with patch.object(ctx, "_run") as p: ctx._output_state = "foo" # would normally be set within the _run call scope diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 7b6d1727..0ab845b9 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -4,7 +4,7 @@ from ops.framework import Framework from scenario import ActionFailed, Context -from scenario.state import State, _Action, next_action_id +from scenario.state import State, _Action, _next_action_id @pytest.fixture(scope="function") @@ -199,7 +199,7 @@ def test_positional_arguments(): def test_default_arguments(): - expected_id = next_action_id(update=False) + expected_id = _next_action_id(update=False) name = "foo" action = _Action(name) assert action.name == name diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 44433e21..b7880425 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -14,14 +14,14 @@ from scenario import Context from scenario.state import ( - DEFAULT_JUJU_DATABAG, + _DEFAULT_JUJU_DATABAG, PeerRelation, Relation, RelationBase, State, StateValidationError, SubordinateRelation, - next_relation_id, + _next_relation_id, ) from tests.helpers import trigger @@ -265,19 +265,19 @@ def callback(charm: CharmBase, event): def test_relation_default_unit_data_regular(): relation = Relation("baz") - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG - assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG} def test_relation_default_unit_data_sub(): relation = SubordinateRelation("baz") - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG - assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG def test_relation_default_unit_data_peer(): relation = PeerRelation("baz") - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG @pytest.mark.parametrize( @@ -431,7 +431,7 @@ def test_relation_positional_arguments(klass): def test_relation_default_values(): - expected_id = next_relation_id(update=False) + expected_id = _next_relation_id(update=False) endpoint = "database" interface = "postgresql" relation = Relation(endpoint, interface) @@ -439,15 +439,15 @@ def test_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG assert relation.remote_app_name == "remote" assert relation.limit == 1 assert relation.remote_app_data == {} - assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} + assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG} def test_subordinate_relation_default_values(): - expected_id = next_relation_id(update=False) + expected_id = _next_relation_id(update=False) endpoint = "database" interface = "postgresql" relation = SubordinateRelation(endpoint, interface) @@ -455,15 +455,15 @@ def test_subordinate_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG assert relation.remote_app_name == "remote" assert relation.remote_unit_id == 0 assert relation.remote_app_data == {} - assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG def test_peer_relation_default_values(): - expected_id = next_relation_id(update=False) + expected_id = _next_relation_id(update=False) endpoint = "peers" interface = "shared" relation = PeerRelation(endpoint, interface) @@ -471,5 +471,5 @@ def test_peer_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG - assert relation.peers_data == {0: DEFAULT_JUJU_DATABAG} + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.peers_data == {0: _DEFAULT_JUJU_DATABAG} diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index d6e3aa5c..9cd1e9c0 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -8,7 +8,7 @@ from ops.model import ActiveStatus, UnknownStatus, WaitingStatus from scenario.state import ( - DEFAULT_JUJU_DATABAG, + _DEFAULT_JUJU_DATABAG, Address, BindAddress, Container, @@ -236,13 +236,13 @@ def pre_event(charm: CharmBase): replace( relation, local_app_data={"a": "b"}, - local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, + local_unit_data={"c": "d", **_DEFAULT_JUJU_DATABAG}, ) ) assert out.get_relation(relation.id).local_app_data == {"a": "b"} assert out.get_relation(relation.id).local_unit_data == { "c": "d", - **DEFAULT_JUJU_DATABAG, + **_DEFAULT_JUJU_DATABAG, } diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index b54c84b4..8a324dbc 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -1,9 +1,8 @@ -import pytest from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent from scenario import State -from scenario.capture_events import capture_events +from scenario.runtime import _capture_events from scenario.state import _Event from tests.helpers import trigger @@ -33,7 +32,7 @@ def _on_foo(self, e): def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): - with capture_events(include_framework=True) as emitted: + with _capture_events(include_framework=True) as emitted: trigger(State(), "start", MyCharm, meta=MyCharm.META) assert len(emitted) == 5 @@ -45,7 +44,7 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): def test_capture_juju_evt(): - with capture_events() as emitted: + with _capture_events() as emitted: trigger(State(), "start", MyCharm, meta=MyCharm.META) assert len(emitted) == 2 @@ -55,7 +54,7 @@ def test_capture_juju_evt(): def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well - with capture_events() as emitted: + with _capture_events() as emitted: trigger( State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", @@ -71,7 +70,7 @@ def test_capture_deferred_evt(): def test_capture_no_deferred_evt(): # todo: this test should pass with ops < 2.1 as well - with capture_events(include_deferred=False) as emitted: + with _capture_events(include_deferred=False) as emitted: trigger( State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", From 1da9684e56193c6f170ea48aa51c6faf62f774f4 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 29 Aug 2024 21:57:46 +1200 Subject: [PATCH 30/35] docs: add instructions for moving from 6.x to 7.x (#143) Since 7.x has so many breaking changes, add an `UPGRADING.md` doc that lists them all, with before/after examples. Note that this is *not* the full release notes for 7.x - it doesn't cover any of the non-breaking changes, other than when they are incidentally used as part of the examples. We can write release notes for the release fairly shortly, which can be more comprehensive (but probably not have as many examples). --- .gitignore | 1 + UPGRADING.md | 408 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tox.ini | 2 +- 4 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 UPGRADING.md diff --git a/.gitignore b/.gitignore index a2f1492c..3d453226 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ __pycache__/ dist/ *.pytest_cache htmlcov/ +.vscode diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 00000000..16df72fa --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,408 @@ +# Upgrading + +## Scenario 6.x to Scenario 7.x + +Scenario 7.0 has substantial API incompatibility with earlier versions, but +comes with an intention to reduce the frequency of breaking changes in the +future, aligning with the `ops` library. + +The changes listed below are not the only features introduced in Scenario 7.0 +(for that, see the release notes), but cover the breaking changes where you will +need to update your existing Scenario tests. + +### Specify events via context.on + +In previous versions of Scenario, an event would be passed to `Context.run` +as a string name, via a convenient shorthand property of a state component +(e.g. `Relation`, `Container`), or by explicitly constructing an `Event` object. +These have been unified into a single `Context.on.{event name}()` approach, +which is more consistent, resembles the structure you're familiar with from +charm `observe` calls, and should provide more context to IDE and linting tools. + +```python +# Older Scenario code. +ctx.run('start', state) +ctx.run(container.pebble_ready_event, state) +ctx.run(Event('relation-joined', relation=relation), state) + +# Scenario 7.x +ctx.run(ctx.on.start(), state) +ctx.run(ctx.on.pebble_ready(container=container), state) +ctx.run(ctx.on.relation_joined(relation=relation), state) +``` + +The same applies to action events: + +```python +# Older Scenario code. +action = Action("backup", params={...}) +ctx.run_action(action, state) + +# Scenario 7.x +ctx.run(ctx.on.action("backup", params={...}), state) +``` + +### Provide State components as (frozen) sets + +The state components were previously lists, but containers, relations, networks, +and other state components do not have any inherent ordering. This led to +'magic' numbers creeping into test code. These are now all sets, and have 'get' +methods to retrieve the object you want to assert on. In addition, they are +actually `frozenset`s (Scenario will automatically freeze them if you pass a +`set`), which increases the immutability of the state and prevents accidentally +modifying the input state. + +```python +# Older Scenario code. +state_in = State(containers=[c1, c2], relations=[r1, r2]) +... +assert state_out.containers[1]... +assert state_out.relations[0]... +state_out.relations.append(r3) # Not recommended! + +# Scenario 7.x +state_in = State(containers={c1, c2}, relations={r1, r2}) +... +assert state_out.get_container(c2.name)... +assert state_out.get_relation(id=r1.id)... +new_state = dataclasses.replace(state_out, relations=state_out.relations + {r3}) +``` + +### Run action events in the same way as other events + +Previously, to run an action event Scenario offered a `run_action` method that +returned an object containing the result of the action. The `run_action()` +method (top-level and on the context manager) has been unified with the `run()` +method. All events, including action events, are run with `run()` and return a +`State` object. The action logs and history are available via the `Context` +object, and if the charm calls `event.fail()`, an exception will be raised. + +```python +# Older Scenario Code +action = Action("backup", params={...}) +out = ctx.run_action(action, state) +assert out.logs == ["baz", "qux"] +assert not out.success +assert out.results == {"foo": "bar"} +assert out.failure == "boo-hoo" + +# Scenario 7.x +with pytest.raises(ActionFailure) as exc_info: + ctx.run(ctx.on.action("backup", params={...}), State()) +assert ctx.action_logs == ['baz', 'qux'] +assert ctx.action_results == {"foo": "bar"} +assert exc_info.value.message == "boo-hoo" +``` + +### Use the Context object as a context manager + +The deprecated `pre_event` and `post_event` arguments to `run` +(and `run_action`) have been removed: use the context handler instead. In +addition, the `Context` object itself is now used for a context manager, rather +than having `.manager()` and `action_manager()` methods. + +In addition, the `.output` attribute of the context manager has been removed. +The state should be accessed explicitly by using the return value of the +`run()` method. + +```python +# Older Scenario code. +ctx = Context(MyCharm) +state = ctx.run("start", pre_event=lambda charm: charm.prepare(), state=State()) + +ctx = Context(MyCharm) +with ctx.manager("start", State()) as mgr: + mgr.charm.prepare() +assert mgr.output.... + +# Scenario 7.x +ctx = Context(MyCharm) +with ctx(ctx.on.start(), State()) as manager: + manager.charm.prepare() + out = manager.run() + assert out... +``` + +### Pass State components are by keyword + +Previously, it was possible (but inadvisable) to use positional arguments for +the `State` and its components. Most state components, and the `State` object +itself, now request at least some arguments to be passed by keyword. In most +cases, it's likely that you were already doing this, but the API is now +enforced. + +```python +# Older Scenario code. +container1 = Container('foo', True) +state = State({'key': 'value'}, [relation1, relation2], [network], [container1, container2]) + +# Scenario 7.x +container1 = Container('foo', can_connect=True) +state = State( + config={'key': 'value'}, + relations={relation1, relation2}, + networks={network}, + containers={container1, container2}, +) +``` + +### Pass only the tracked and latest content to Secrets + +In the past, any number of revision contents were provided when creating a +`Secret. Now, rather than having a dictionary of many revisions as part of `Secret` +objects, only the tracked and latest revision content needs to be included. +These are the only revisions that the charm has access to, so any other +revisions are not required. In addition, there's no longer a requirement to +pass in an ID. + +```python +# Older Scenario code. +state = State( + secrets=[ + scenario.Secret( + id='foo', + contents={0: {'certificate': 'xxxx'}} + ), + scenario.Secret( + id='foo', + contents={ + 0: {'password': '1234'}, + 1: {'password': 'abcd'}, + 2: {'password': 'admin'}, + } + ), + ] +) + +# Scenario 7.x +state = State( + secrets={ + scenario.Secret({'certificate': 'xxxx'}), + scenario.Secret( + tracked_content={'password': '1234'}, + latest_content={'password': 'admin'}, + ), + } +) +``` + +### Trigger custom events by triggering the underlying Juju event + +Scenario no longer supports explicitly running custom events. Instead, you +should run the Juju event(s) that will trigger the custom event. For example, +if you have a charm lib that will emit a `database-created` event on +`relation-created`: + +```python +# Older Scenario code. +ctx.run("my_charm_lib.on.database_created", state) + +# Scenario 7.x +ctx.run(ctx.on.relation_created(relation=relation), state) +``` + +Scenario will still capture custom events in `Context.emitted_events`. + +### Copy objects with dataclasses.replace and copy.deepcopy + +The `copy()` and `replace()` methods of `State` and the various state components +have been removed. You should use the `dataclasses.replace` and `copy.deepcopy` +methods instead. + +```python +# Older Scenario code. +new_container = container.replace(can_connect=True) +duplicate_relation = relation.copy() + +# Scenario 7.x +new_container = dataclasses.replace(container, can_connect=True) +duplicate_relation = copy.deepcopy(relation) +``` + +### Define resources with the Resource class + +The resources in State objects were previously plain dictionaries, and are now +`scenario.Resource` objects, aligning with all of the other State components. + +```python +# Older Scenario code +state = State(resources={"/path/to/foo", pathlib.Path("/mock/foo")}) + +# Scenario 7.x +resource = Resource(location="/path/to/foo", source=pathlib.Path("/mock/foo")) +state = State(resources={resource}) +``` + +### Give Network objects a binding name attribute + +Previously, `Network` objects were added to the state as a dictionary of +`{binding_name: network}`. Now, `Network` objects are added to the state as a +set, like the other components. This means that the `Network` object now +requires a binding name to be passed in when it is created. + +```python +# Older Scenario code +state = State(networks={"foo": Network.default()}) + +# Scenario 7.x +state = State(networks={Network.default("foo")}) +``` + +### Use the .deferred() method to populate State.deferred + +Previously, there were multiple methods to populate the `State.deferred` list: +events with a `.deferred()` method, the `scenario.deferred()` method, and +creating a `DeferredEvent` object manually. Now, for Juju events, you should +always use the `.deferred()` method of the event -- this also ensures that the +deferred event has all of the required links (to relations, containers, secrets, +and so on). + +```python +# Older Scenario code +deferred_start = scenario.deferred('start', handler=MyCharm._on_start) +deferred_relation_created = Relation('foo').changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +deferred_config_changed = DeferredEvent( + handle_path='MyCharm/on/config_changed[1]', + owner='MyCharm', + observer='_on_config_changed' +) + +# Scenario 7.x +deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) +deferred_relation_changed = ctx.on.relation_changed(Relation('foo')).deferred(handler=MyCharm._on_foo_relation_changed) +deferred_config_changed = ctx.on.config_changed().deferred(handler=MyCharm._on_config_changed) +``` + +### Update names: State.storages, State.stored_states, Container.execs, Container.service_statuses + +The `State.storage` and `State.stored_state` attributes are now plurals. This +reflects that you may have more than one in the state, and also aligns with the +other State components. + +```python +# Older Scenario code +state = State(stored_state=[ss1, ss2], storage=[s1, s2]) + +# Scenario 7.x +state = State(stored_states={s1, s2}, storages={s1, s2}) +``` + +Similarly, `Container.exec_mocks` is now named `Container.execs`, +`Container.service_status` is now named `Container.service_statuses`, and +`ExecOutput` is now named `Exec`. + +```python +# Older Scenario code +container = Container( + name="foo", + exec_mock={("ls", "-ll"): ExecOutput(return_code=0, stdout=....)}, + service_status={"srv1": ops.pebble.ServiceStatus.ACTIVE} +) + +# Scenario 7.x +container = Container( + name="foo", + execs={Exec(["ls", "-ll"], return_code=0, stdout=....)}, + service_statuses={"srv1": ops.pebble.ServiceStatus.ACTIVE}, +) +``` + +### Don't use `Event`, or `StoredState.data_type_name` + +Several attributes and classes that were never intended for end users have been +made private: + +* The `data_type_name` attribute of `StoredState` is now private. +* The `Event` class is now private. + +### Use Catan rather than `scenario.sequences` + +The `scenario.sequences` module has been removed. We encourage you to look at +the new [Catan](https://github.com/PietroPasotti/catan) package. + +### Use the jsonpatch library directly + +The `State.jsonpatch_delta()` and `state.sort_patch()` methods have been +removed. We are considering adding delta-comparisons of state again in the +future, but have not yet decided how this will look. In the meantime, you can +use the jsonpatch package directly if necessary. See the tests/helpers.py file +for an example. + +### Remove calls to `cleanup`/`clear` + +The `Context.cleanup()` and `Context.clear()` methods have been removed. You +do not need to manually call any cleanup methods after running an event. If you +want a fresh `Context` (e.g. with no history), you should create a new object. + +### Include secrets in the state only if the charm has permission to view them + +`Secret.granted` has been removed. Only include in the state the secrets that +the charm has permission to (at least) view. + +### Use 'app' for application-owned secrets + +`Secret.owner` should be `'app'` (or `'unit'` or `None`) rather than +`'application'`. + +### Compare statuses with status objects + +It is no longer possible to compare statuses with tuples. Create the appropriate +status object and compare to that. Note that you should always compare statuses +with `==` not `is`. + +### Pass the name of the container to `State.get_container` + +The `State.get_container` method previously allowed passing in a `Container` +object or a container name, but now only accepts a name. This is more consistent +with the other new `get_*` methods, some of which would be quite complex if they +accepted an object or key. + +### Use `State.storages` to get all the storages in the state + +The `State.get_storages` method has been removed. This was primarily intended +for internal use. You can use `State.get_storage` or iterate through +`State.storages` instead. + +### Use .replace() to change can_connect, leader, and unit_status + +The `State` class previously had convenience methods `with_can_connect`, +`with_leadership`, and `with_unit_status`. You should now use the regular +`.replace()` mechanism instead. + +```python +# Older Scenario code +new_state = state.with_can_connect(container_name, can_connect=True) +new_state = state.with_leadership(leader=True) +new_state = state.with_unit_status(status=ActiveStatus()) + +# Scenario 7.x +new_container = dataclasses.replace(container, can_connect=True) +new_state = dataclasses.replace(containers={container}) +new_state = dataclasses.replace(state, leader=True) +new_state = dataclasses.replace(state, status=ActiveStatus()) +``` + +### Let Scenario handle the relation, action, and notice IDs, and storage index + +Scenario previously had `next_relation_id`, `next_action_id`, +`next_storage_index`, and `next_notice_id` methods. You should now let Scenario +manage the IDs and indexes of these objects. + +### Get the output state from the run() call + +The `Context` class previously had an `output_state` attribute that held the +most recent output state. You should now get the output state from the `run()` +return value. + +### Don't use internal details + +The `*_SUFFIX`, and `_EVENTS` names, the `hook_tool_output_fmt()` methods, the +`normalize_name` method, the `DEFAULT_JUJU_VERSION` and `DEFAULT_JUJU_DATABAG` +names have all been removed, and shouldn't need replacing. + +The `capture_events` and `consistency_checker` modules are also no longer +available for public use - the consistency checker will still run automatically, +and the `Context` class has attributes for capturing events. + +The `AnyRelation` and `PathLike` names have been removed: use `RelationBase` and +`str | Path` instead. diff --git a/pyproject.toml b/pyproject.toml index b1f030d8..801e263f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.15", + "ops==2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/tox.ini b/tox.ini index 9ecb3a32..149dfbaf 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 From 7f7c07d4d7bf7acd04dbc3f30873ee50abb1e05c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 30 Aug 2024 16:46:11 +1200 Subject: [PATCH 31/35] docs: expand the reference documentation (#169) Expands the docstrings so that when used with Sphinx's autodoc they will fit in with the other ops docs on ops.rtd but also still work standalone. The Sphinx autodoc system uses the `__new__` signature in preference to the `__init__` one, which means that by default all classes that are using the `_MaxPositionalArgs` class have a `*args, **kwargs` signature, which is not informative. custom_conf.py monkeypatches Sphinx to work around this, including tweaking the signature so that the `*` appears in the correct place for the maximum number of positional arguments. Also bumps the Sphinx version to align with ops. --------- Co-authored-by: PietroPasotti --- docs/custom_conf.py | 26 +++- docs/index.rst | 18 +-- docs/requirements.txt | 100 +++++++++------- pyproject.toml | 2 +- scenario/__init__.py | 59 ++++++++- scenario/_consistency_checker.py | 79 +++++++----- scenario/context.py | 200 +++++++++++++++++++++---------- scenario/runtime.py | 2 +- scenario/state.py | 193 ++++++++++++++++++++++------- 9 files changed, 491 insertions(+), 188 deletions(-) diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 70bf3e10..a4993d4b 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -31,6 +31,7 @@ def _compute_navigation_tree(context): r'''^ ([\w.]+::)? # explicit module name ([\w.]+\.)? # module and/or class name(s) ([^.()]+) \s* # thing name + (?: \[\s*(.*)\s*])? # optional: type parameters list, Sphinx 7&8 (?: \((.*)\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -306,9 +307,32 @@ def _compute_navigation_tree(context): # ('envvar', 'LD_LIBRARY_PATH'). nitpick_ignore = [ # Please keep this list sorted alphabetically. - ('py:class', '_CharmSpec'), ('py:class', '_Event'), + ('py:class', '_EntityStatus'), + ('py:class', 'ModelError'), # This is in a copied docstring so we can't fix it. ('py:class', 'scenario.state._EntityStatus'), ('py:class', 'scenario.state._Event'), ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] + +# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses +# of _MaxPositionalArgs. +import inspect +import sphinx.ext.autodoc + +_real_get_signature = sphinx.ext.autodoc.ClassDocumenter._get_signature + +def _custom_get_signature(self): + if any(p.__name__ == '_MaxPositionalArgs' for p in self.object.__mro__): + signature = inspect.signature(self.object) + parameters = [] + for position, param in enumerate(signature.parameters.values()): + if position >= self.object._max_positional_args: + parameters.append(param.replace(kind=inspect.Parameter.KEYWORD_ONLY)) + else: + parameters.append(param) + signature = signature.replace(parameters=parameters) + return None, None, signature + return _real_get_signature(self) + +sphinx.ext.autodoc.ClassDocumenter._get_signature = _custom_get_signature diff --git a/docs/index.rst b/docs/index.rst index 272af959..a64e16f7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,21 +1,13 @@ - Scenario API reference ====================== -.. toctree:: - :maxdepth: 2 - :caption: Contents: - -scenario.State -============== - -.. automodule:: scenario.state - +scenario +======== -scenario.Context -================ +.. automodule:: scenario + :special-members: __call__ -.. automodule:: scenario.context +.. automodule:: scenario.errors Indices diff --git a/docs/requirements.txt b/docs/requirements.txt index 7b02bdf0..f517d485 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,53 +1,65 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml # -alabaster==0.7.13 +alabaster==1.0.0 # via sphinx -babel==2.14.0 +anyio==4.4.0 + # via + # starlette + # watchfiles +babel==2.16.0 # via sphinx beautifulsoup4==4.12.3 # via # canonical-sphinx-extensions # furo # pyspelling -bracex==2.4 +bracex==2.5 # via wcmatch -canonical-sphinx-extensions==0.0.19 +canonical-sphinx-extensions==0.0.23 # via ops-scenario (pyproject.toml) -certifi==2024.2.2 +certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests +click==8.1.7 + # via uvicorn colorama==0.4.6 # via sphinx-autobuild -docutils==0.19 +docutils==0.21.2 # via # canonical-sphinx-extensions # myst-parser # sphinx # sphinx-tabs -furo==2024.1.29 +furo==2024.8.6 # via ops-scenario (pyproject.toml) +gitdb==4.0.11 + # via gitpython +gitpython==3.1.43 + # via canonical-sphinx-extensions +h11==0.14.0 + # via uvicorn html5lib==1.1 # via pyspelling -idna==3.6 - # via requests +idna==3.8 + # via + # anyio + # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.3 +jinja2==3.1.4 # via # myst-parser # sphinx linkify-it-py==2.0.3 # via ops-scenario (pyproject.toml) -livereload==2.6.3 - # via sphinx-autobuild -lxml==5.2.1 +lxml==5.3.0 # via pyspelling -markdown==3.6 +markdown==3.7 # via pyspelling markdown-it-py==3.0.0 # via @@ -55,44 +67,46 @@ markdown-it-py==3.0.0 # myst-parser markupsafe==2.1.5 # via jinja2 -mdit-py-plugins==0.4.0 +mdit-py-plugins==0.4.1 # via myst-parser mdurl==0.1.2 # via markdown-it-py -myst-parser==2.0.0 +myst-parser==4.0.0 # via ops-scenario (pyproject.toml) -ops==2.12.0 +ops==2.15.0 # via ops-scenario (pyproject.toml) -packaging==24.0 +packaging==24.1 # via sphinx -pygments==2.17.2 +pygments==2.18.0 # via # furo # sphinx # sphinx-tabs pyspelling==2.10 # via ops-scenario (pyproject.toml) -pyyaml==6.0.1 +pyyaml==6.0.2 # via # myst-parser # ops # ops-scenario (pyproject.toml) # pyspelling -requests==2.31.0 +requests==2.32.3 # via # canonical-sphinx-extensions # sphinx six==1.16.0 - # via - # html5lib - # livereload + # via html5lib +smmap==5.0.1 + # via gitdb +sniffio==1.3.1 + # via anyio snowballstemmer==2.2.0 # via sphinx -soupsieve==2.5 +soupsieve==2.6 # via # beautifulsoup4 # pyspelling -sphinx==6.2.1 +sphinx==8.0.2 # via # canonical-sphinx-extensions # furo @@ -106,43 +120,49 @@ sphinx==6.2.1 # sphinx-tabs # sphinxcontrib-jquery # sphinxext-opengraph -sphinx-autobuild==2024.2.4 +sphinx-autobuild==2024.4.16 # via ops-scenario (pyproject.toml) sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 # via ops-scenario (pyproject.toml) -sphinx-design==0.5.0 +sphinx-design==0.6.1 # via ops-scenario (pyproject.toml) -sphinx-notfound-page==1.0.0 +sphinx-notfound-page==1.0.4 # via ops-scenario (pyproject.toml) sphinx-tabs==3.4.5 # via ops-scenario (pyproject.toml) -sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jquery==4.1 # via ops-scenario (pyproject.toml) sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphinxext-opengraph==0.9.1 # via ops-scenario (pyproject.toml) -tornado==6.4 - # via livereload +starlette==0.38.2 + # via sphinx-autobuild uc-micro-py==1.0.3 # via linkify-it-py -urllib3==2.2.1 +urllib3==2.2.2 # via requests -wcmatch==8.5.1 +uvicorn==0.30.6 + # via sphinx-autobuild +watchfiles==0.24.0 + # via sphinx-autobuild +wcmatch==9.0 # via pyspelling webencodings==0.5.1 # via html5lib -websocket-client==1.7.0 +websocket-client==1.8.0 # via ops +websockets==13.0.1 + # via sphinx-autobuild diff --git a/pyproject.toml b/pyproject.toml index 801e263f..03ffdfc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ docs = [ "linkify-it-py", "myst-parser", "pyspelling", - "sphinx==6.2.1", + "sphinx ~= 8.0.0", "sphinx-autobuild", "sphinx-copybutton", "sphinx-design", diff --git a/scenario/__init__.py b/scenario/__init__.py index 3439daa1..2ba5a24c 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -2,6 +2,64 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +"""Charm state-transition testing SDK for Ops charms. + +Write tests that declaratively define the Juju state all at once, define the +Juju context against which to test the charm, and fire a single event on the +charm to execute its logic. The tests can then assert that the Juju state has +changed as expected. + +These tests are 'state-transition' tests, a way to test isolated units of charm +functionality (how the state changes in reaction to events). They are not +necessarily tests of individual methods or functions (but might be, depending on +the charm's event observers); they are testing the 'contract' of the charm: given +a certain state, when a certain event happens, the charm should transition to a +certain (likely different) state. They do not test against a real Juju +controller and model, and focus on a single Juju unit, unlike integration tests. +For simplicity, we refer to them as 'unit' tests in the charm context. + +Writing these tests should nudge you into thinking of a charm as a black-box +input->output function. The input is the union of an `Event` (why am I, charm, +being executed), a `State` (am I leader? what is my integration data? what is my +config?...) and the charm's execution `Context` (what integrations can I have? +what containers can I have?...). The output is another `State`: the state after +the charm has had a chance to interact with the mocked Juju model and affect the +state. + +.. image:: https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png + :alt: Transition diagram, with the input state and event on the left, the context including the charm in the centre, and the state out on the right + +Writing unit tests for a charm, then, means verifying that: + +- the charm does not raise uncaught exceptions while handling the event +- the output state (as compared with the input state) is as expected. + +A test consists of three broad steps: + +- **Arrange**: + - declare the context + - declare the input state +- **Act**: + - select an event to fire + - run the context (i.e. obtain the output state, given the input state and the event) +- **Assert**: + - verify that the output state (as compared with the input state) is how you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + - optionally, you can use a context manager to get a hold of the charm instance and run + assertions on APIs and state internal to it. + +The most basic scenario is one in which all is defaulted and barely any data is +available. The charm has no config, no integrations, no leadership, and its +status is `unknown`. With that, we can write the simplest possible test: + +.. code-block:: python + + def test_base(): + ctx = Context(MyCharm) + out = ctx.run(ctx.on.start(), State()) + assert out.unit_status == UnknownStatus() +""" + from scenario.context import Context, Manager from scenario.state import ( ActionFailed, @@ -87,5 +145,4 @@ "UnitID", "UnknownStatus", "WaitingStatus", - "deferred", ] diff --git a/scenario/_consistency_checker.py b/scenario/_consistency_checker.py index 68fd3c24..748806ff 100644 --- a/scenario/_consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -1,6 +1,23 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +""" +The :meth:`check_consistency` function is the primary entry point for the +consistency checks. Calling it ensures that the :class:`State` and the event, +in combination with the ``Context``, is viable in Juju. For example, Juju can't +emit a ``foo-relation-changed`` event on your charm unless your charm has +declared a ``foo`` relation endpoint in its metadata. + +Normally, there is no need to explicitly call this function; that happens +automatically behind the scenes whenever you trigger an event. + +If you have a clear false negative, are explicitly testing 'edge', +inconsistent situations, or for whatever reason the checker is in your way, you +can set the ``SCENARIO_SKIP_CONSISTENCY_CHECKS`` environment variable and skip +it altogether. +""" + import marshal import os import re @@ -26,7 +43,11 @@ class Results(NamedTuple): - """Consistency checkers return type.""" + """Consistency checker return type. + + Each consistency check function returns a ``Results`` instance with the + warnings and errors found during the check. + """ errors: Iterable[str] warnings: Iterable[str] @@ -38,20 +59,22 @@ def check_consistency( charm_spec: "_CharmSpec", juju_version: str, ): - """Validate the combination of a state, an event, a charm spec, and a juju version. - - When invoked, it performs a series of checks that validate that the state is consistent with - itself, with the event being emitted, the charm metadata, etc... - - This function performs some basic validation of the combination of inputs that goes into a - scenario test and determines if the scenario is a realistic/plausible/consistent one. - - A scenario is inconsistent if it can practically never occur because it contradicts - the juju model. - For example: juju guarantees that upon calling config-get, a charm will only ever get the keys - it declared in its config.yaml. - So a State declaring some config keys that are not in the charm's config.yaml is nonsense, - and the combination of the two is inconsistent. + """Validate the combination of a state, an event, a charm spec, and a Juju version. + + When invoked, it performs a series of checks that validate that the state is + consistent with itself, with the event being emitted, the charm metadata, + and so on. + + This function performs some basic validation of the combination of inputs + that goes into a test and determines if the scenario is a + realistic/plausible/consistent one. + + A scenario is inconsistent if it can practically never occur because it + contradicts the Juju model. For example: Juju guarantees that upon calling + ``config-get``, a charm will only ever get the keys it declared in its + config metadata, so a :class:`scenario.State` declaring some config keys + that are not in the charm's ``charmcraft.yaml`` is nonsense, and the + combination of the two is inconsistent. """ juju_version_: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) @@ -103,7 +126,7 @@ def check_resource_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the resources from metadata and in State.""" + """Check the internal consistency of the resources from metadata and in :class:`scenario.State`.""" errors = [] warnings = [] @@ -125,7 +148,7 @@ def check_event_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the _Event data structure. + """Check the internal consistency of the ``_Event`` data structure. For example, it checks that a relation event has a relation instance, and that the relation endpoint name matches the event prefix. @@ -335,7 +358,7 @@ def check_storages_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" + """Check the consistency of the :class:`scenario.State` storages with the charm_spec metadata.""" state_storage = state.storages meta_storage = (charm_spec.meta or {}).get("storage", {}) errors = [] @@ -373,7 +396,7 @@ def check_config_consistency( juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" + """Check the consistency of the :class:`scenario.State` config with the charm_spec config.""" state_config = state.config meta_config = (charm_spec.config or {}).get("options", {}) errors = [] @@ -381,7 +404,8 @@ def check_config_consistency( for key, value in state_config.items(): if key not in meta_config: errors.append( - f"config option {key!r} in state.config but not specified in config.yaml.", + f"config option {key!r} in state.config but not specified in config.yaml or " + f"charmcraft.yaml.", ) continue @@ -431,7 +455,7 @@ def check_secrets_consistency( juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of Secret-related stuff.""" + """Check the consistency of any :class:`scenario.Secret` in the :class:`scenario.State`.""" errors = [] if not event._is_secret_event: return Results(errors, []) @@ -458,6 +482,7 @@ def check_network_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: + """Check the consistency of any :class:`scenario.Network` in the :class:`scenario.State`.""" errors = [] meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) @@ -474,7 +499,7 @@ def check_network_consistency( state_bindings = {network.binding_name for network in state.networks} if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): errors.append( - f"Some network bindings defined in State are not in metadata.yaml: {diff}.", + f"Some network bindings defined in State are not in the metadata: {diff}.", ) endpoints = {endpoint for endpoint, metadata in all_relations} @@ -493,6 +518,7 @@ def check_relation_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: + """Check the consistency of any relations in the :class:`scenario.State`.""" errors = [] peer_relations_meta = charm_spec.meta.get("peers", {}).items() @@ -562,7 +588,7 @@ def check_containers_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of `state.containers` vs. `charm_spec.meta`.""" + """Check the consistency of :class:`scenario.State` containers with the charm_spec metadata.""" # event names will be normalized; need to compare against normalized container names. meta = charm_spec.meta @@ -625,7 +651,7 @@ def check_cloudspec_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check that Kubernetes charms/models don't have `state.cloud_spec`.""" + """Check that Kubernetes models don't have :attr:`scenario.State.cloud_spec` set.""" errors = [] warnings = [] @@ -633,8 +659,7 @@ def check_cloudspec_consistency( if state.model.type == "kubernetes" and state.model.cloud_spec: errors.append( "CloudSpec is only available for machine charms, not Kubernetes charms. " - "Tell Scenario to simulate a machine substrate with: " - "`scenario.State(..., model=scenario.Model(type='lxd'))`.", + "Simulate a machine substrate with: `State(..., model=Model(type='lxd'))`.", ) return Results(errors, warnings) @@ -645,7 +670,7 @@ def check_storedstate_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of `state.stored_states`.""" + """Check the internal consistency of any :class:`scenario.StoredState` in the :class:`scenario.State`.""" errors = [] # Attribute names must be unique on each object. diff --git a/scenario/context.py b/scenario/context.py index 67759789..08bfdd50 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +import functools import tempfile from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast -from ops import CharmBase, EventBase -from ops.testing import ExecArgs +import ops +import ops.testing from scenario.errors import AlreadyEmittedError, ContextSetupError from scenario.logger import logger as scenario_logger @@ -26,8 +28,6 @@ ) if TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - from scenario.ops_main_mock import Ops from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus @@ -64,12 +64,16 @@ def __init__( self.output: Optional["State"] = None @property - def charm(self) -> CharmBase: + def charm(self) -> ops.CharmBase: + """The charm object instantiated by ops to handle the event. + + The charm is only available during the context manager scope. + """ if not self.ops: raise RuntimeError( "you should __enter__ this context manager before accessing this", ) - return cast(CharmBase, self.ops.charm) + return cast(ops.CharmBase, self.ops.charm) @property def _runner(self): @@ -104,63 +108,87 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 self.run() -class _CharmEvents: - """Events generated by Juju pertaining to application lifecycle. +def _copy_doc(original_func): + """Copy the docstring from `original_func` to the wrapped function.""" + + def decorator(wrapper_func): + @functools.wraps(wrapper_func) + def wrapped(*args, **kwargs): + return wrapper_func(*args, **kwargs) + + wrapped.__doc__ = original_func.__doc__ + return wrapped - By default, the events listed as attributes of this class will be - provided via the :attr:`Context.on` attribute. For example:: + return decorator + + +class CharmEvents: + """Events generated by Juju or ops pertaining to the application lifecycle. + + The events listed as attributes of this class should be accessed via the + :attr:`Context.on` attribute. For example:: ctx.run(ctx.on.config_changed(), state) This behaves similarly to the :class:`ops.CharmEvents` class but is much - simpler as there are no dynamically named attributes, and no __getattr__ + simpler as there are no dynamically named attributes, and no ``__getattr__`` version to get events. In addition, all of the attributes are methods, - which are used to connect the event to the specific container object that - they relate to (or, for simpler events like "start" or "stop", take no - arguments). + which are used to connect the event to the specific object that they relate + to (or, for simpler events like "start" or "stop", take no arguments). """ @staticmethod + @_copy_doc(ops.InstallEvent) def install(): return _Event("install") @staticmethod + @_copy_doc(ops.StartEvent) def start(): return _Event("start") @staticmethod + @_copy_doc(ops.StopEvent) def stop(): return _Event("stop") @staticmethod + @_copy_doc(ops.RemoveEvent) def remove(): return _Event("remove") @staticmethod + @_copy_doc(ops.UpdateStatusEvent) def update_status(): return _Event("update_status") @staticmethod + @_copy_doc(ops.ConfigChangedEvent) def config_changed(): return _Event("config_changed") @staticmethod + @_copy_doc(ops.UpdateStatusEvent) def upgrade_charm(): return _Event("upgrade_charm") @staticmethod + @_copy_doc(ops.PreSeriesUpgradeEvent) def pre_series_upgrade(): return _Event("pre_series_upgrade") @staticmethod + @_copy_doc(ops.PostSeriesUpgradeEvent) def post_series_upgrade(): return _Event("post_series_upgrade") @staticmethod + @_copy_doc(ops.LeaderElectedEvent) def leader_elected(): return _Event("leader_elected") @staticmethod + @_copy_doc(ops.SecretChangedEvent) def secret_changed(secret: Secret): if secret.owner: raise ValueError( @@ -169,6 +197,7 @@ def secret_changed(secret: Secret): return _Event("secret_changed", secret=secret) @staticmethod + @_copy_doc(ops.SecretExpiredEvent) def secret_expired(secret: Secret, *, revision: int): if not secret.owner: raise ValueError( @@ -177,6 +206,7 @@ def secret_expired(secret: Secret, *, revision: int): return _Event("secret_expired", secret=secret, secret_revision=revision) @staticmethod + @_copy_doc(ops.SecretRotateEvent) def secret_rotate(secret: Secret): if not secret.owner: raise ValueError( @@ -185,6 +215,7 @@ def secret_rotate(secret: Secret): return _Event("secret_rotate", secret=secret) @staticmethod + @_copy_doc(ops.SecretRemoveEvent) def secret_remove(secret: Secret, *, revision: int): if not secret.owner: raise ValueError( @@ -194,17 +225,21 @@ def secret_remove(secret: Secret, *, revision: int): @staticmethod def collect_app_status(): + """Event triggered at the end of every hook to collect app statuses for evaluation""" return _Event("collect_app_status") @staticmethod def collect_unit_status(): + """Event triggered at the end of every hook to collect unit statuses for evaluation""" return _Event("collect_unit_status") @staticmethod + @_copy_doc(ops.RelationCreatedEvent) def relation_created(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_created", relation=relation) @staticmethod + @_copy_doc(ops.RelationJoinedEvent) def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_joined", @@ -213,6 +248,7 @@ def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = No ) @staticmethod + @_copy_doc(ops.RelationChangedEvent) def relation_changed( relation: "RelationBase", *, @@ -225,6 +261,7 @@ def relation_changed( ) @staticmethod + @_copy_doc(ops.RelationDepartedEvent) def relation_departed( relation: "RelationBase", *, @@ -239,22 +276,27 @@ def relation_departed( ) @staticmethod + @_copy_doc(ops.RelationBrokenEvent) def relation_broken(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_broken", relation=relation) @staticmethod + @_copy_doc(ops.StorageAttachedEvent) def storage_attached(storage: Storage): return _Event(f"{storage.name}_storage_attached", storage=storage) @staticmethod + @_copy_doc(ops.StorageDetachingEvent) def storage_detaching(storage: Storage): return _Event(f"{storage.name}_storage_detaching", storage=storage) @staticmethod + @_copy_doc(ops.PebbleReadyEvent) def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) @staticmethod + @_copy_doc(ops.PebbleCustomNoticeEvent) def pebble_custom_notice(container: Container, notice: Notice): return _Event( f"{container.name}_pebble_custom_notice", @@ -263,6 +305,7 @@ def pebble_custom_notice(container: Container, notice: Notice): ) @staticmethod + @_copy_doc(ops.PebbleCheckFailedEvent) def pebble_check_failed(container: Container, info: CheckInfo): return _Event( f"{container.name}_pebble_check_failed", @@ -271,6 +314,7 @@ def pebble_check_failed(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.PebbleCheckRecoveredEvent) def pebble_check_recovered(container: Container, info: CheckInfo): return _Event( f"{container.name}_pebble_check_recovered", @@ -279,6 +323,7 @@ def pebble_check_recovered(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.ActionEvent) def action( name: str, params: Optional[Dict[str, "AnyJson"]] = None, @@ -295,13 +340,15 @@ def action( class Context: """Represents a simulated charm's execution context. - It is the main entry point to running a scenario test. + The main entry point to running a test. It contains: - It contains: the charm source code being executed, the metadata files associated with it, - a charm project repository root, and the Juju version to be simulated. + - the charm source code being executed + - the metadata files associated with it + - a charm project repository root + - the Juju version to be simulated - After you have instantiated ``Context``, typically you will call ``run()``to execute the charm - once, write any assertions you like on the output state returned by the call, write any + After you have instantiated ``Context``, typically you will call :meth:`run()` to execute the + charm once, write any assertions you like on the output state returned by the call, write any assertions you like on the ``Context`` attributes, then discard the ``Context``. Each ``Context`` instance is in principle designed to be single-use: @@ -310,63 +357,77 @@ class Context: Any side effects generated by executing the charm, that are not rightful part of the ``State``, are in fact stored in the ``Context``: - - :attr:`juju_log`: record of what the charm has sent to juju-log - - :attr:`app_status_history`: record of the app statuses the charm has set - - :attr:`unit_status_history`: record of the unit statuses the charm has set - - :attr:`workload_version_history`: record of the workload versions the charm has set - - :attr:`removed_secret_revisions`: record of the secret revisions the charm has removed - - :attr:`emitted_events`: record of the events (including custom) that the charm has processed - - :attr:`action_logs`: logs associated with the action output, set by the charm with - :meth:`ops.ActionEvent.log` - - :attr:`action_results`: key-value mapping assigned by the charm as a result of the action. - Will be None if the charm never calls :meth:`ops.ActionEvent.set_results` + - :attr:`juju_log` + - :attr:`app_status_history` + - :attr:`unit_status_history` + - :attr:`workload_version_history` + - :attr:`removed_secret_revisions` + - :attr:`requested_storages` + - :attr:`emitted_events` + - :attr:`action_logs` + - :attr:`action_results` This allows you to write assertions not only on the output state, but also, to some extent, on the path the charm took to get there. - A typical scenario test will look like:: + A typical test will look like:: - from scenario import Context, State - from ops import ActiveStatus from charm import MyCharm, MyCustomEvent # noqa def test_foo(): # Arrange: set the context up - c = Context(MyCharm) + ctx = Context(MyCharm) # Act: prepare the state and emit an event - state_out = c.run('update-status', State()) + state_out = ctx.run(ctx.on.update_status(), State()) # Assert: verify the output state is what you think it should be assert state_out.unit_status == ActiveStatus('foobar') # Assert: verify the Context contains what you think it should assert len(c.emitted_events) == 4 assert isinstance(c.emitted_events[3], MyCustomEvent) - If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the - execution cwd, you need to use the ``charm_root`` argument. For example:: - - import scenario - import tempfile - virtual_root = tempfile.TemporaryDirectory() - local_path = Path(local_path.name) - (local_path / 'foo').mkdir() - (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - scenario.Context(... charm_root=virtual_root).run(...) - If you need access to the charm object that will handle the event, use the class in a ``with`` statement, like:: - import scenario - def test_foo(): - ctx = scenario.Context(MyCharm) + ctx = Context(MyCharm) with ctx(ctx.on.start(), State()) as manager: manager.charm._some_private_setup() manager.run() """ + juju_log: List["JujuLogLine"] + """A record of what the charm has sent to juju-log""" + app_status_history: List["_EntityStatus"] + """A record of the app statuses the charm has set""" + unit_status_history: List["_EntityStatus"] + """A record of the unit statuses the charm has set""" + workload_version_history: List[str] + """A record of the workload versions the charm has set""" + removed_secret_revisions: List[int] + """A record of the secret revisions the charm has removed""" + emitted_events: List[ops.EventBase] + """A record of the events (including custom) that the charm has processed""" + requested_storages: Dict[str, int] + """A record of the storages the charm has requested""" + action_logs: List[str] + """The logs associated with the action output, set by the charm with :meth:`ops.ActionEvent.log` + + This will be empty when handling a non-action event. + """ + action_results: Optional[Dict[str, Any]] + """A key-value mapping assigned by the charm as a result of the action. + + This will be ``None`` if the charm never calls :meth:`ops.ActionEvent.set_results` + """ + on: CharmEvents + """The events that this charm can respond to. + + Use this when calling :meth:`run` to specify the event to emit. + """ + def __init__( self, - charm_type: Type["CharmType"], + charm_type: Type[ops.testing.CharmType], meta: Optional[Dict[str, Any]] = None, *, actions: Optional[Dict[str, Any]] = None, @@ -381,7 +442,17 @@ def __init__( ): """Represents a simulated charm's execution context. - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. + If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the + execution cwd, you need to use the ``charm_root`` argument. For example:: + + import tempfile + virtual_root = tempfile.TemporaryDirectory() + local_path = Path(local_path.name) + (local_path / 'foo').mkdir() + (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + Context(... charm_root=virtual_root).run(...) + + :arg charm_type: the :class:`ops.CharmBase` subclass to handle the event. :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). If none is provided, we will search for a ``metadata.yaml`` file in the charm root. :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict). @@ -390,11 +461,11 @@ def __init__( If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. :arg app_name: App name that this charm is deployed as. Defaults to the charm name as - defined in metadata.yaml. - :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. + defined in the metadata. + :arg unit_id: Unit ID that this charm is deployed as. :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with - ``juju trust``). Defaults to False. - :arg charm_root: virtual charm root the charm will be executed with. + ``juju trust``). + :arg charm_root: virtual charm filesystem root the charm will be executed with. """ if not any((meta, actions, config)): @@ -438,10 +509,10 @@ def __init__( self.juju_log: List["JujuLogLine"] = [] self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] - self.exec_history: Dict[str, List[ExecArgs]] = {} + self.exec_history: Dict[str, List[ops.testing.ExecArgs]] = {} self.workload_version_history: List[str] = [] self.removed_secret_revisions: List[int] = [] - self.emitted_events: List[EventBase] = [] + self.emitted_events: List[ops.EventBase] = [] self.requested_storages: Dict[str, int] = {} # set by Runtime.exec() in self._run() @@ -452,7 +523,7 @@ def __init__( self.action_results: Optional[Dict[str, Any]] = None self._action_failure_message: Optional[str] = None - self.on = _CharmEvents() + self.on = CharmEvents() def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" @@ -488,20 +559,21 @@ def __call__(self, event: "_Event", state: "State"): assert manager.charm._some_private_attribute == "bar" # noqa Args: - event: the :class:`Event` that the charm will respond to. - state: the :class:`State` instance to use when handling the Event. + event: the event that the charm will respond to. + state: the :class:`State` instance to use when handling the event. """ return Manager(self, event, state) def run(self, event: "_Event", state: "State") -> "State": - """Trigger a charm execution with an Event and a State. + """Trigger a charm execution with an event and a State. Calling this function will call ``ops.main`` and set up the context according to the - specified ``State``, then emit the event on the charm. + specified :class:`State`, then emit the event on the charm. - :arg event: the Event that the charm will respond to. - :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the Event. + :arg event: the event that the charm will respond to. Use the :attr:`on` attribute to + specify the event; for example: ``ctx.on.start()``. + :arg state: the :class:`State` instance to use as data source for the hook tool calls that + the charm will invoke when handling the event. """ if event.action: # Reset the logs, failure status, and results, in case the context diff --git a/scenario/runtime.py b/scenario/runtime.py index 754829c0..f4df73db 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -121,7 +121,7 @@ def apply_state(self, state: "State"): db.save_snapshot(event.handle_path, event.snapshot_data) for stored_state in state.stored_states: - db.save_snapshot(stored_state.handle_path, stored_state.content) + db.save_snapshot(stored_state._handle_path, stored_state.content) db.close() diff --git a/scenario/state.py b/scenario/state.py index 33d5f280..3a72f20b 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -10,7 +10,6 @@ import random import re import string -from collections import namedtuple from enum import Enum from itertools import chain from pathlib import Path, PurePosixPath @@ -48,8 +47,6 @@ from scenario.errors import MetadataNotFoundError, StateValidationError from scenario.logger import logger as scenario_logger -JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) - if TYPE_CHECKING: # pragma: no cover from scenario import Context @@ -116,7 +113,7 @@ class ActionFailed(Exception): - """Raised at the end of the hook if the charm has called `event.fail()`.""" + """Raised at the end of the hook if the charm has called ``event.fail()``.""" def __init__(self, message: str, state: "State"): self.message = message @@ -192,8 +189,20 @@ def __reduce__(self): return _MaxPositionalArgs +@dataclasses.dataclass(frozen=True) +class JujuLogLine(_max_posargs(2)): + """An entry in the Juju debug-log.""" + + level: str + """The level of the message, for example ``INFO`` or ``ERROR``.""" + message: str + """The log message.""" + + @dataclasses.dataclass(frozen=True) class CloudCredential(_max_posargs(0)): + __doc__ = ops.CloudCredential.__doc__ + auth_type: str """Authentication type.""" @@ -217,6 +226,8 @@ def _to_ops(self) -> CloudCredential_Ops: @dataclasses.dataclass(frozen=True) class CloudSpec(_max_posargs(1)): + __doc__ = ops.CloudSpec.__doc__ + type: str """Type of the cloud.""" @@ -273,23 +284,51 @@ def _generate_secret_id(): @dataclasses.dataclass(frozen=True) class Secret(_max_posargs(1)): + """A Juju secret. + + This class is used for both user and charm secrets. + """ + tracked_content: "RawSecretRevisionContents" + """The content of the secret that the charm is currently tracking. + + This is the content the charm will receive with a + :meth:`ops.Secret.get_content` call.""" latest_content: Optional["RawSecretRevisionContents"] = None + """The content of the latest revision of the secret. + + This is the content the charm will receive with a + :meth:`ops.Secret.peek_content` call.""" id: str = dataclasses.field(default_factory=_generate_secret_id) + """The Juju ID of the secret. + + This is automatically assigned and should not usually need to be explicitly set. + """ - # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. - # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None + """Indicates if the secret is owned by *this* unit, *this* application, or + another application/unit. + + If None, the implication is that read access to the secret has been granted + to this unit. + """ - # mapping from relation IDs to remote unit/apps to which this secret has been granted. - # Only applicable if owner remote_grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict) + """Mapping from relation IDs to remote units and applications to which this + secret has been granted.""" label: Optional[str] = None + """A human-readable label the charm can use to retrieve the secret. + + If this is set, it implies that the charm has previously set the label. + """ description: Optional[str] = None + """A human-readable description of the secret.""" expire: Optional[datetime.datetime] = None + """The time at which the secret will expire.""" rotate: Optional[SecretRotate] = None + """The rotation policy for the secret.""" # what revision is currently tracked by this charm. Only meaningful if owner=False _tracked_revision: int = 1 @@ -375,8 +414,11 @@ class BindAddress(_max_posargs(1)): interface_name: str addresses: List[Address] + """The addresses in the space.""" interface_name: str = "" + """The name of the network interface.""" mac_address: Optional[str] = None + """The MAC address of the interface.""" def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would @@ -392,16 +434,22 @@ def _hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) class Network(_max_posargs(2)): + """A Juju network space.""" + binding_name: str + """The name of the network space.""" bind_addresses: List[BindAddress] = dataclasses.field( default_factory=lambda: [BindAddress([Address("192.0.2.0")])], ) + """Addresses that the charm's application should bind to.""" ingress_addresses: List[str] = dataclasses.field( default_factory=lambda: ["192.0.2.0"], ) + """Addresses other applications should use to connect to the unit.""" egress_subnets: List[str] = dataclasses.field( default_factory=lambda: ["192.0.2.0/24"], ) + """Subnets that other units will see the charm connecting from.""" def __hash__(self) -> int: return hash(self.binding_name) @@ -421,6 +469,11 @@ def _hook_tool_output_fmt(self): def _next_relation_id(*, update=True): + """Get the ID the next relation to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -431,11 +484,11 @@ def _next_relation_id(*, update=True): @dataclasses.dataclass(frozen=True) class RelationBase(_max_posargs(2)): endpoint: str - """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" + """Relation endpoint name. Must match some endpoint name defined in the metadata.""" interface: Optional[str] = None - """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. - If left empty, it will be automatically derived from metadata.yaml.""" + """Interface name. Must match the interface name attached to this endpoint in the metadata. + If left empty, it will be automatically derived from the metadata.""" id: int = dataclasses.field(default_factory=_next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, @@ -546,14 +599,19 @@ def _databags(self): @dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): + """A relation to share data between a subordinate and a principal charm.""" + remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + """The current content of the remote application databag.""" remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) + """The current content of the remote unit databag.""" - # app name and ID of the remote unit that *this unit* is attached to. remote_app_name: str = "remote" + """The name of the remote application that *this unit* is attached to.""" remote_unit_id: int = 0 + """The ID of the remote unit that *this unit* is attached to.""" def __hash__(self) -> int: return hash(self.id) @@ -582,6 +640,7 @@ def _databags(self): @property def remote_unit_name(self) -> str: + """The full name of the remote unit, in the form ``remote/0``.""" return f"{self.remote_app_name}/{self.remote_unit_id}" @@ -713,6 +772,11 @@ def _now_utc(): def _next_notice_id(*, update=True): + """Get the ID the next Pebble notice to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -722,6 +786,8 @@ def _next_notice_id(*, update=True): @dataclasses.dataclass(frozen=True) class Notice(_max_posargs(1)): + """A Pebble notice.""" + key: str """The notice key, a string that differentiates notices of this type. @@ -781,6 +847,8 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) class CheckInfo(_max_posargs(1)): + """A health check for a Pebble workload container.""" + name: str """Name of the check.""" @@ -790,9 +858,10 @@ class CheckInfo(_max_posargs(1)): status: pebble.CheckStatus = pebble.CheckStatus.UP """Status of the check. - CheckStatus.UP means the check is healthy (the number of failures is less - than the threshold), CheckStatus.DOWN means the check is unhealthy - (the number of failures has reached the threshold). + :attr:`ops.pebble.CheckStatus.UP` means the check is healthy (the number of + failures is fewer than the threshold), :attr:`ops.pebble.CheckStatus.DOWN` + means the check is unhealthy (the number of failures has reached the + threshold). """ failures: int = 0 @@ -801,7 +870,7 @@ class CheckInfo(_max_posargs(1)): threshold: int = 3 """Failure threshold. - This is how many consecutive failures for the check to be considered “down”. + This is how many consecutive failures for the check to be considered 'down'. """ def _to_ops(self) -> pebble.CheckInfo: @@ -831,7 +900,7 @@ class Container(_max_posargs(1)): # will be unknown. all that we can know is the resulting plan (the 'computed plan'). _base_plan: dict = dataclasses.field(default_factory=dict) # We expect most of the user-facing testing to be covered by this 'layers' attribute, - # as all will be known when unit-testing. + # as it is all that will be known when unit-testing. layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) """All :class:`ops.pebble.Layer` definitions that have already been added to the container.""" @@ -866,8 +935,8 @@ class Container(_max_posargs(1)): this becomes:: mounts = { - 'foo': scenario.Mount('/home/foo', Path('/path/to/local/dir/containing/bar/py/')), - 'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')), + 'foo': Mount('/home/foo', pathlib.Path('/path/to/local/dir/containing/bar/py/')), + 'bin': Mount('/bin/', pathlib.Path('/path/to/local/dir/containing/bash/and/baz/')), } """ @@ -879,7 +948,7 @@ class Container(_max_posargs(1)): For example:: - container = scenario.Container( + container = Container( name='foo', execs={ scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'), @@ -893,8 +962,10 @@ class Container(_max_posargs(1)): """ notices: List[Notice] = dataclasses.field(default_factory=list) + """Any Pebble notices that already exist in the container.""" check_infos: FrozenSet[CheckInfo] = frozenset() + """All Pebble health checks that have been added to the container.""" def __hash__(self) -> int: return hash(self.name) @@ -917,9 +988,9 @@ def _render_services(self): def plan(self) -> pebble.Plan: """The 'computed' Pebble plan. - i.e. the base plan plus the layers that have been added on top. - You should run your assertions on this plan, not so much on the layers, as those are - input data. + This is the base plan plus the layers that have been added on top. + You should run your assertions on this plan, not so much on the layers, + as those are input data. """ # copied over from ops.testing._TestingPebbleClient.get_plan(). @@ -1087,26 +1158,40 @@ def __init__(self, message: str = ""): @dataclasses.dataclass(frozen=True) class StoredState(_max_posargs(1)): + """Represents unit-local state that persists across events.""" + name: str = "_stored" + """The attribute in the parent Object where the state is stored. + + For example, ``_stored`` in this class:: + + class MyCharm(ops.CharmBase): + _stored = ops.StoredState() + + """ - # /-separated Object names. E.g. MyCharm/MyCharmLib. - # if None, this StoredState instance is owned by the Framework. owner_path: Optional[str] = None + """The path to the owner of this StoredState instance. + + If None, the owner is the Framework. Otherwise, /-separated object names, + for example MyCharm/MyCharmLib. + """ # Ideally, the type here would be only marshallable types, rather than Any. # However, it's complex to describe those types, since it's a recursive # definition - even in TypeShed the _Marshallable type includes containers # like list[Any], which seems to defeat the point. content: Dict[str, Any] = dataclasses.field(default_factory=dict) + """The content of the :class:`ops.StoredState` instance.""" _data_type_name: str = "StoredStateData" @property - def handle_path(self): + def _handle_path(self): return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]" def __hash__(self) -> int: - return hash(self.handle_path) + return hash(self._handle_path) _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] @@ -1116,8 +1201,8 @@ def __hash__(self) -> int: class Port(_max_posargs(1)): """Represents a port on the charm host. - Port objects should not be instantiated directly: use TCPPort, UDPPort, or - ICMPPort instead. + Port objects should not be instantiated directly: use :class:`TCPPort`, + :class:`UDPPort`, or :class:`ICMPPort` instead. """ port: Optional[int] = None @@ -1146,6 +1231,10 @@ class TCPPort(Port): port: int """The port to open.""" protocol: _RawPortProtocolLiteral = "tcp" + """The protocol that data transferred over the port will use. + + :meta private: + """ def __post_init__(self): super().__post_init__() @@ -1162,6 +1251,10 @@ class UDPPort(Port): port: int """The port to open.""" protocol: _RawPortProtocolLiteral = "udp" + """The protocol that data transferred over the port will use. + + :meta private: + """ def __post_init__(self): super().__post_init__() @@ -1176,6 +1269,10 @@ class ICMPPort(Port): """Represents an ICMP port on the charm host.""" protocol: _RawPortProtocolLiteral = "icmp" + """The protocol that data transferred over the port will use. + + :meta private: + """ _max_positional_args: Final = 0 @@ -1210,12 +1307,16 @@ def _next_storage_index(*, update=True): @dataclasses.dataclass(frozen=True) class Storage(_max_posargs(1)): - """Represents an (attached!) storage made available to the charm container.""" + """Represents an (attached) storage made available to the charm container.""" name: str + """The name of the storage, as found in the charm metadata.""" index: int = dataclasses.field(default_factory=_next_storage_index) - # Every new Storage instance gets a new one, if there's trouble, override. + """The index of this storage instance. + + For Kubernetes charms, this will always be 1. For machine charms, each new + Storage instance gets a new index.""" def __eq__(self, other: object) -> bool: if isinstance(other, (Storage, ops.Storage)): @@ -1232,15 +1333,17 @@ class Resource(_max_posargs(0)): """Represents a resource made available to the charm.""" name: str + """The name of the resource, as found in the charm metadata.""" path: Union[str, Path] + """A local path that will be provided to the charm as the content of the resource.""" @dataclasses.dataclass(frozen=True) class State(_max_posargs(0)): - """Represents the juju-owned portion of a unit's state. + """Represents the Juju-owned portion of a unit's state. Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its - lifecycle. For example, status-get will return data from `State.status`, is-leader will + lifecycle. For example, status-get will return data from `State.unit_status`, is-leader will return data from `State.leader`, and so on. """ @@ -1254,31 +1357,36 @@ class State(_max_posargs(0)): """Manual overrides for any relation and extra bindings currently provisioned for this charm. If a metadata-defined relation endpoint is not explicitly mapped to a Network in this field, it will be defaulted. - [CAVEAT: `extra-bindings` is a deprecated, regretful feature in juju/ops. For completeness we - support it, but use at your own risk.] If a metadata-defined extra-binding is left empty, - it will be defaulted. + + .. warning:: + `extra-bindings` is a deprecated, regretful feature in Juju/ops. For completeness we + support it, but use at your own risk. If a metadata-defined extra-binding is left empty, + it will be defaulted. """ containers: Iterable[Container] = dataclasses.field(default_factory=frozenset) """All containers (whether they can connect or not) that this charm is aware of.""" storages: Iterable[Storage] = dataclasses.field(default_factory=frozenset) - """All ATTACHED storage instances for this charm. + """All **attached** storage instances for this charm. + If a storage is not attached, omit it from this listing.""" # we don't use sets to make json serialization easier opened_ports: Iterable[Port] = dataclasses.field(default_factory=frozenset) - """Ports opened by juju on this charm.""" + """Ports opened by Juju on this charm.""" leader: bool = False """Whether this charm has leadership.""" model: Model = Model() """The model this charm lives in.""" secrets: Iterable[Secret] = dataclasses.field(default_factory=frozenset) """The secrets this charm has access to (as an owner, or as a grantee). + The presence of a secret in this list entails that the charm can read it. Whether it can manage it or not depends on the individual secret's `owner` flag.""" resources: Iterable[Resource] = dataclasses.field(default_factory=frozenset) """All resources that this charm can access.""" planned_units: int = 1 """Number of non-dying planned units that are expected to be running this application. + Use with caution.""" # Represents the OF's event queue. These events will be emitted before the event being @@ -1561,8 +1669,8 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: class DeferredEvent: """An event that has been deferred to run prior to the next Juju event. - Tests should not instantiate this class directly: use :meth:`_Event.deferred` - instead. For example: + Tests should not instantiate this class directly: use the `deferred` method + of the event instead. For example: ctx = Context(MyCharm) deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) @@ -1883,6 +1991,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def _next_action_id(*, update=True): + """Get the ID the next action to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_action_id_counter cur = _next_action_id_counter if update: From 537cc0d02ceadc85a1b682289b53283520ad0f3a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 2 Sep 2024 21:53:24 +1200 Subject: [PATCH 32/35] Fix merge. --- pyproject.toml | 2 +- scenario/_consistency_checker.py | 4 +++- scenario/ops_main_mock.py | 15 +++++++++++++-- scenario/state.py | 6 +++--- tests/test_e2e/test_network.py | 27 ++++----------------------- tests/test_e2e/test_secrets.py | 28 ---------------------------- tox.ini | 2 +- 7 files changed, 25 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03ffdfc2..eb6695b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops==2.15", + "ops~=2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/_consistency_checker.py b/scenario/_consistency_checker.py index 748806ff..e8807aeb 100644 --- a/scenario/_consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -497,7 +497,9 @@ 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)): + if diff := state_bindings.difference( + meta_bindings.union(non_sub_relations).union(implicit_bindings), + ): errors.append( f"Some network bindings defined in State are not in the metadata: {diff}.", ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index cc7391cc..27f00a8b 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -28,6 +28,17 @@ # pyright: reportPrivateUsage=false +# TODO: Use ops.jujucontext's _JujuContext.charm_dir. +def _get_charm_dir(): + charm_dir = os.environ.get("JUJU_CHARM_DIR") + if charm_dir is None: + # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. + charm_dir = pathlib.Path(f"{__file__}/../../..").resolve() + else: + charm_dir = pathlib.Path(charm_dir).resolve() + return charm_dir + + def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: """Walk path on root to an ObjectEvents instance.""" obj = root @@ -70,7 +81,7 @@ def _emit_charm_event( ) try: - args, kwargs = _get_event_args(charm, event_to_emit) + args, kwargs = _get_event_args(charm, event_to_emit) # type: ignore except TypeError: # ops 2.16+ import ops.jujucontext # type: ignore @@ -169,7 +180,7 @@ def setup( charm_dir = _get_charm_dir() try: - dispatcher = _Dispatcher(charm_dir) + dispatcher = _Dispatcher(charm_dir) # type: ignore except TypeError: # ops 2.16+ import ops.jujucontext # type: ignore diff --git a/scenario/state.py b/scenario/state.py index 3a72f20b..b93d9f76 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -412,7 +412,6 @@ def address(self, value): class BindAddress(_max_posargs(1)): """An address bound to a network interface in a Juju space.""" - interface_name: str addresses: List[Address] """The addresses in the space.""" interface_name: str = "" @@ -557,6 +556,7 @@ def _validate_databag(self, databag: dict): @dataclasses.dataclass(frozen=True) class Relation(RelationBase): """An integration between the charm and another application.""" + remote_app_name: str = "remote" """The name of the remote application, as in the charm's metadata.""" @@ -760,7 +760,7 @@ class Mount(_max_posargs(0)): location: Union[str, PurePosixPath] """The location inside of the container.""" - src: Union[str, Path] + source: Union[str, Path] """The content to provide when the charm does :meth:`ops.Container.pull`.""" @@ -1770,7 +1770,7 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: @dataclasses.dataclass(frozen=True) -class Event: +class _Event: """A Juju, ops, or custom event that can be run against a charm. Typically, for simple events, the string name (e.g. ``install``) can be used, diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index a09d09f6..7fe78667 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -133,8 +133,8 @@ def test_juju_info_network_default(mycharm): meta={"name": "foo"}, ) - with ctx.manager( - "update_status", + with ctx( + ctx.on.update_status(), State(), ) as mgr: # we have a network for the relation @@ -144,25 +144,6 @@ def test_juju_info_network_default(mycharm): ) -def test_juju_info_network_override(mycharm): - ctx = Context( - mycharm, - meta={"name": "foo"}, - ) - - with ctx.manager( - "update_status", - State( - networks={"juju-info": Network.default(private_address="4.4.4.4")}, - ), - ) as mgr: - # we have a network for the relation - assert ( - str(mgr.charm.model.get_binding("juju-info").network.bind_address) - == "4.4.4.4" - ) - - def test_explicit_juju_info_network_override(mycharm): ctx = Context( mycharm, @@ -173,8 +154,8 @@ def test_explicit_juju_info_network_override(mycharm): }, ) - with ctx.manager( - "update_status", + with ctx( + ctx.on.update_status(), State(), ) as mgr: assert mgr.charm.model.get_binding("juju-info").network.bind_address diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index fb1590b7..0d5730df 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -573,34 +573,6 @@ def _on_secret_remove(self, event): ) -@pytest.mark.parametrize( - "evt,owner,cls", - ( - ("changed", None, SecretChangedEvent), - ("rotate", "app", SecretRotateEvent), - ("expired", "app", SecretExpiredEvent), - ("remove", "app", SecretRemoveEvent), - ), -) -def test_emit_event(evt, owner, cls): - class MyCharm(CharmBase): - def __init__(self, framework): - super().__init__(framework) - for evt in self.on.events().values(): - self.framework.observe(evt, self._on_event) - self.events = [] - - def _on_event(self, event): - self.events.append(event) - - ctx = Context(MyCharm, meta={"name": "local"}) - secret = Secret(contents={"foo": "bar"}, id="foo", owner=owner) - with ctx.manager(getattr(secret, evt + "_event"), State(secrets=[secret])) as mgr: - mgr.run() - juju_event = mgr.charm.events[0] # Ignore collect-status etc. - assert isinstance(juju_event, cls) - - def test_set_label_on_get(): class SecretCharm(CharmBase): def __init__(self, framework): diff --git a/tox.ini b/tox.ini index 149dfbaf..d3155731 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ commands = description = Static typing checks. skip_install = true deps = - ops==2.15 + ops~=2.15 pyright==1.1.347 commands = pyright scenario From 150a73b099169efb859d1963daaa2329936d9264 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 3 Sep 2024 17:36:23 +1200 Subject: [PATCH 33/35] Let action params be a Mapping rather than strictly a dict. --- scenario/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index b93d9f76..b35081f6 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -25,6 +25,7 @@ Iterable, List, Literal, + Mapping, Optional, Sequence, Set, @@ -2023,7 +2024,7 @@ def test_backup_action(): name: str """Juju action name, as found in the charm metadata.""" - params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) + params: Mapping[str, "AnyJson"] = dataclasses.field(default_factory=dict) """Parameter values passed to the action.""" id: str = dataclasses.field(default_factory=_next_action_id) From 987b82416787756a431fbd100d5a8ff1623470f8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 3 Sep 2024 17:39:57 +1200 Subject: [PATCH 34/35] Allow passing a Mapping for the action params, not just a dict. --- scenario/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 08bfdd50..7b41ec7c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -6,7 +6,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type, Union, cast import ops import ops.testing @@ -326,7 +326,7 @@ def pebble_check_recovered(container: Container, info: CheckInfo): @_copy_doc(ops.ActionEvent) def action( name: str, - params: Optional[Dict[str, "AnyJson"]] = None, + params: Optional[Mapping[str, "AnyJson"]] = None, id: Optional[str] = None, ): kwargs = {} From c848415912c01f9a9cbf07e7ccc235d6d47c1f06 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 5 Sep 2024 11:41:35 +1200 Subject: [PATCH 35/35] chore: add some code to ease the transition from 6.x to 7.0 (#185) * Remove `Manager.output` as noticed by @PietroPasotti * If `Context.run_action` is called, raise an error but point them towards the solution. * If `Context.run` is called with a string or callable event, raise an error but point them towards the solution * If `Relation.relation_id` is used, raise an error but point towards `.id` --- .gitignore | 1 + scenario/context.py | 57 ++++++++++++++++++++++++++++++++++++++++++++- scenario/state.py | 10 ++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3d453226..dd6ae82f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ venv/ +.venv/ build/ docs/_build/ *.charm diff --git a/scenario/context.py b/scenario/context.py index 7b41ec7c..cb5331d5 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -61,7 +61,6 @@ def __init__( self._emitted: bool = False self.ops: Optional["Ops"] = None - self.output: Optional["State"] = None @property def charm(self) -> ops.CharmBase: @@ -564,6 +563,16 @@ def __call__(self, event: "_Event", state: "State"): """ return Manager(self, event, state) + def run_action(self, action: str, state: "State"): + """Use `run()` instead. + + :private: + """ + raise AttributeError( + f"call with `ctx.run`, like `ctx.run(ctx.on.action({action!r})` " + "and find the results in `ctx.action_results`", + ) + def run(self, event: "_Event", state: "State") -> "State": """Trigger a charm execution with an event and a State. @@ -575,6 +584,52 @@ def run(self, event: "_Event", state: "State") -> "State": :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. """ + # Help people transition from Scenario 6: + if isinstance(event, str): + event = event.replace("-", "_") # type: ignore + if event in ( + "install", + "start", + "stop", + "remove", + "update_status", + "config_changed", + "upgrade_charm", + "pre_series_upgrade", + "post_series_upgrade", + "leader_elected", + "collect_app_status", + "collect_unit_status", + ): + suggested = f"{event}()" + elif event in ("secret_changed", "secret_rotate"): + suggested = f"{event}(my_secret)" + elif event in ("secret_expired", "secret_remove"): + suggested = f"{event}(my_secret, revision=1)" + elif event in ( + "relation_created", + "relation_joined", + "relation_changed", + "relation_departed", + "relation_broken", + ): + suggested = f"{event}(my_relation)" + elif event in ("storage_attached", "storage_detaching"): + suggested = f"{event}(my_storage)" + elif event == "pebble_ready": + suggested = f"{event}(my_container)" + elif event == "pebble_custom_notice": + suggested = f"{event}(my_container, my_notice)" + else: + suggested = "event()" + raise TypeError( + f"call with an event from `ctx.on`, like `ctx.on.{suggested}`", + ) + if callable(event): + raise TypeError( + "You should call the event method. Did you forget to add parentheses?", + ) + if event.action: # Reset the logs, failure status, and results, in case the context # is reused. diff --git a/scenario/state.py b/scenario/state.py index b35081f6..e7190ea5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -26,6 +26,7 @@ List, Literal, Mapping, + NoReturn, Optional, Sequence, Set, @@ -502,6 +503,14 @@ class RelationBase(_max_posargs(2)): ) """This unit's databag for this relation.""" + @property + def relation_id(self) -> NoReturn: + """Use `.id` instead of `.relation_id`. + + :private: + """ + raise AttributeError("use .id instead of .relation_id") + @property def _databags(self): """Yield all databags in this relation.""" @@ -1435,6 +1444,7 @@ def __post_init__(self): ] if self.storages != normalised_storage: object.__setattr__(self, "storages", normalised_storage) + # ops.Container, ops.Model, ops.Relation, ops.Secret should not be instantiated by charmers. # ops.Network does not have the relation name, so cannot be converted. # ops.Resources does not contain the source of the resource, so cannot be converted.