Skip to content

Commit

Permalink
Rename the context objects.
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyandrewmeyer committed Jul 24, 2024
1 parent efac1c4 commit edcc70c
Show file tree
Hide file tree
Showing 4 changed files with 49 additions and 64 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1127,18 +1127,18 @@ 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"
# such as stored state:
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"
Expand All @@ -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
Expand Down
57 changes: 22 additions & 35 deletions scenario/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,14 @@ class _Manager:
def __init__(
self,
ctx: "Context",
arg: Union[str, _Action, _Event],
arg: _Event,
state_in: "State",
):
self._ctx = ctx
self._arg = arg
self._state_in = state_in

self._emitted: bool = False
self._run = None

self.ops: Optional["Ops"] = None
self.output: Optional[Union["State", ActionOutput]] = None
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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::
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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)
Expand Down
16 changes: 4 additions & 12 deletions tests/test_e2e/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -23,45 +23,37 @@ def _on_event(self, e):
if isinstance(e, CollectStatusEvent):
return

print("event!")
self.unit.status = ActiveStatus(e.handle.kind)

return MyCharm


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)
Expand Down
26 changes: 16 additions & 10 deletions tests/test_e2e/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"}]


Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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():
Expand Down

0 comments on commit edcc70c

Please sign in to comment.