diff --git a/README.md b/README.md index 06957bf2..b57edd03 100644 --- a/README.md +++ b/README.md @@ -1105,7 +1105,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 @@ -1127,10 +1127,9 @@ 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 event: # This is your charm instance, after ops has set it up: - charm: MyCharm = manager.charm + charm: MyCharm = event.charm # We can check attributes on nested Objects or the charm itself: assert charm.my_charm_lib.foo == "foo" @@ -1138,7 +1137,8 @@ def test_live_charm_introspection(mycharm): assert charm._stored.a == "a" # This will tell ops.main to proceed with normal execution and emit the "start" event on the charm: - state_out = manager.run() + # If you want to do this with actions, you should use `run_action` instead. + state_out = event.run() # After that is done, we are handed back control, and we can again do some introspection: assert charm.my_charm_lib.foo == "bar" @@ -1149,8 +1149,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 `event.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/scenario/context.py b/scenario/context.py index 7167d14e..8a90ae67 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -83,7 +83,7 @@ class _Manager: def __init__( self, ctx: "Context", - arg: Union[str, _Action, _Event], + arg: _Event, state_in: "State", ): self._ctx = ctx @@ -91,7 +91,6 @@ 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 @@ -106,7 +105,7 @@ def charm(self) -> CharmBase: @property def _runner(self): - raise NotImplementedError("override in subclass") + return self._ctx._run # noqa def _get_output(self): raise NotImplementedError("override in subclass") @@ -117,51 +116,42 @@ def __enter__(self): self.ops = ops return self - def run(self) -> Union[ActionOutput, "State"]: + def _run(self) -> Union[ActionOutput, "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 + return self._get_output() def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 if not self._emitted: - logger.debug("manager not invoked. Doing so implicitly...") - self.run() + logger.debug("event not emitted. Doing so implicitly...") + # The output is discarded so we can use the private method. + self._run() -class _EventManager(_Manager): - if TYPE_CHECKING: # pragma: no cover - output: State # pyright: ignore[reportIncompatibleVariableOverride] +class ManagedEvent(_Manager): + charm: CharmBase # type: ignore - def run(self) -> "State": - return cast("State", super().run()) - - @property - def _runner(self): - return self._ctx._run_event # noqa + def run(self) -> "State": + return cast("State", super()._run()) def _get_output(self): return self._ctx._output_state # noqa -class _ActionManager(_Manager): - output: ActionOutput # pyright: ignore[reportIncompatibleVariableOverride] +class ManagedAction(_Manager): + charm: CharmBase # type: ignore def run_action(self) -> "ActionOutput": - return cast("ActionOutput", super().run()) - - @property - def _runner(self): - return self._ctx._run # noqa + return cast("ActionOutput", super()._run()) def _get_output(self): return self._ctx._finalize_action(self._ctx.output_state) # noqa @@ -599,7 +589,7 @@ def _record_status(self, state: "State", is_app: bool): else: self.unit_status_history.append(state.unit_status) - def __call__(self, event: Union["_Event", "_Action"], state: "State"): + def __call__(self, event: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage:: @@ -620,13 +610,8 @@ def __call__(self, event: Union["_Event", "_Action"], state: "State"): state: the :class:`State` instance to use when handling the Event. """ if isinstance(event, _Action) or event.action: - return _ActionManager(self, event, state) - return _EventManager(self, event, state) - - @contextmanager - def _run_event(self, event: "_Event", state: "State"): - with self._run(event=event, state=state) as ops: - yield ops + return ManagedAction(self, event, state) + return ManagedEvent(self, event, state) def run(self, event: "_Event", state: "State") -> "State": """Trigger a charm execution with an Event and a State. @@ -638,9 +623,9 @@ 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 event.action: raise InvalidEventError("Use run_action() to run an action event.") - with self._run_event(event=event, state=state) as ops: + with self._run(event=event, state=state) as ops: ops.emit() return self.output_state @@ -654,6 +639,8 @@ def run_action(self, event: "_Event", state: "State") -> ActionOutput: :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 not event.action: + raise InvalidEventError("Use run() to run an non-action event.") with self._run(event=event, state=state) as ops: ops.emit() return self._finalize_action(self.output_state) diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 2b401eb6..12ed2fd6 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 ActionOutput, AlreadyEmittedError, ManagedEvent @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,37 +30,30 @@ 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 ManagedEvent(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 ManagedEvent(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 ManagedEvent(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) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index a11c9fa2..03cbe621 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -169,9 +169,10 @@ def test_add(mycharm, app): charm.app.add_secret({"foo": "bar"}, label="mylabel") else: charm.unit.add_secret({"foo": "bar"}, label="mylabel") + output = event.run() - assert event.output.secrets - secret = event.output.get_secret(label="mylabel") + assert output.secrets + secret = output.get_secret(label="mylabel") assert secret.contents[0] == {"foo": "bar"} assert secret.label == "mylabel" @@ -404,7 +405,8 @@ def test_grant(mycharm, app): secret.grant(relation=foo) else: secret.grant(relation=foo, unit=foo.units.pop()) - vals = list(event.output.get_secret(label="mylabel").remote_grants.values()) + output = event.run() + vals = list(output.get_secret(label="mylabel").remote_grants.values()) assert vals == [{"remote"}] if app else [{"remote/0"}] @@ -434,8 +436,9 @@ def test_update_metadata(mycharm): expire=exp, rotate=SecretRotate.DAILY, ) + output = event.run() - secret_out = event.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" @@ -519,25 +522,28 @@ def __init__(self, *args): bar_relation = charm.model.relations["bar"][0] secret.grant(bar_relation) + output = event.run() - assert event.output.secrets - scenario_secret = event.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(ctx.on.start(), event.output) as event: + with ctx(ctx.on.start(), output) as event: charm: GrantingCharm = event.charm secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) + output = event.run() - scenario_secret = event.output.get_secret(label="mylabel") + scenario_secret = output.get_secret(label="mylabel") assert scenario_secret.remote_grants == {} - with ctx(ctx.on.start(), event.output) as event: + with ctx(ctx.on.start(), output) as event: charm: GrantingCharm = event.charm secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() + output = event.run() - assert not event.output.get_secret(label="mylabel").contents # secret wiped + assert not output.get_secret(label="mylabel").contents # secret wiped def test_no_additional_positional_arguments():