Skip to content

Commit

Permalink
PoC for Context as the context manager.
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyandrewmeyer committed Jul 24, 2024
1 parent 7f8e6f2 commit c8a7c75
Show file tree
Hide file tree
Showing 14 changed files with 157 additions and 156 deletions.
41 changes: 16 additions & 25 deletions scenario/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,10 @@ def _get_output(self):


class _ActionManager(_Manager):
if TYPE_CHECKING: # pragma: no cover
output: ActionOutput # pyright: ignore[reportIncompatibleVariableOverride]
output: ActionOutput # pyright: ignore[reportIncompatibleVariableOverride]

def run(self) -> "ActionOutput":
return cast("ActionOutput", super().run())
def run_action(self) -> "ActionOutput":
return cast("ActionOutput", super().run())

@property
def _runner(self):
Expand Down Expand Up @@ -600,38 +599,30 @@ 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: Union["_Event", "Action"], state: "State"):
"""Context manager to introspect live charm object before and after the event is emitted.
Usage::
ctx = Context(MyCharm)
with ctx.manager(ctx.on.start(), 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
with ctx(ctx.on.start(), State()) as event:
event.charm._some_private_setup()
event.run() # this will fire the event
assert event.charm._some_private_attribute == "bar" # noqa
with ctx(Action("foo"), State()) as event:
event.charm._some_private_setup()
event.run_action() # this will fire the event
assert event.charm._some_private_attribute == "bar" # noqa
Args:
event: the :class:`Event` that the charm will respond to.
event: the :class:`Event` or :class:`Action` that the charm will respond to.
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)

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:
Expand Down
8 changes: 4 additions & 4 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ 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 event:
if pre_event:
pre_event(mgr.charm)
state_out = mgr.run()
pre_event(event.charm)
state_out = event.run()
if post_event:
post_event(mgr.charm)
post_event(event.charm)
return state_out


Expand Down
6 changes: 3 additions & 3 deletions tests/test_charm_spec_autoload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
mgr.run()
assert mgr.charm.config["foo"] is True
with ctx(ctx.on.start(), State()) as event:
event.run()
assert event.charm.config["foo"] is True
18 changes: 15 additions & 3 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,21 @@ 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:
assert mgr.charm.app.name == app_name
assert mgr.charm.unit.name == f"{app_name}/{unit_id}"
with ctx(ctx.on.start(), State()) as event:
assert event.charm.app.name == app_name
assert event.charm.unit.name == f"{app_name}/{unit_id}"


def test_context_manager():
ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}})
state = State()
with ctx(ctx.on.start(), state) as event:
event.run()
assert event.charm.meta.name == "foo"

with ctx(Action("act"), state) as event:
event.run_action()
assert event.charm.meta.name == "foo"


def test_action_output_no_positional_arguments():
Expand Down
30 changes: 14 additions & 16 deletions tests/test_context_on.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -95,7 +95,7 @@ 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)
Expand Down Expand Up @@ -123,7 +123,7 @@ 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)
Expand Down Expand Up @@ -159,7 +159,7 @@ 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)
Expand All @@ -173,8 +173,8 @@ 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:
mgr.run()
with ctx(ctx.on.action("act"), scenario.State()) as mgr:
mgr.run_action()
assert len(mgr.charm.observed) == 2
assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
event = mgr.charm.observed[0]
Expand All @@ -187,8 +187,8 @@ def test_action_event_with_params():
# ctx.run_action(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:
mgr.run()
with ctx(call_event, scenario.State()) as mgr:
mgr.run_action()
assert len(mgr.charm.observed) == 2
assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
event = mgr.charm.observed[0]
Expand All @@ -203,7 +203,7 @@ 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)
Expand Down Expand Up @@ -232,7 +232,7 @@ 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)
Expand All @@ -249,7 +249,7 @@ 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]
Expand Down Expand Up @@ -282,7 +282,7 @@ 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)
Expand All @@ -308,9 +308,7 @@ 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)
Expand All @@ -327,7 +325,7 @@ 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()
Expand Down
6 changes: 3 additions & 3 deletions tests/test_e2e/test_cloud_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()
6 changes: 3 additions & 3 deletions tests/test_e2e/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ def test_manager_reemit_fails(mycharm):

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"


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()
with ctx(ctx.on.action("do-x"), State()) as manager:
ao = manager.run_action()
assert isinstance(ao, ActionOutput)
assert ctx.emitted_events[0].handle.kind == "do_x_action"
6 changes: 3 additions & 3 deletions tests/test_e2e/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def test_ip_get(mycharm):
},
)

with ctx.manager(
with ctx(
ctx.on.update_status(),
State(
relations=[
Expand Down Expand Up @@ -77,7 +77,7 @@ def test_no_sub_binding(mycharm):
},
)

with ctx.manager(
with ctx(
ctx.on.update_status(),
State(
relations=[
Expand All @@ -102,7 +102,7 @@ def test_no_relation_error(mycharm):
},
)

with ctx.manager(
with ctx(
ctx.on.update_status(),
State(
relations=[
Expand Down
24 changes: 12 additions & 12 deletions tests/test_e2e/test_pebble.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ def callback(self: CharmBase):
charm_type=charm_cls,
meta={"name": "foo", "containers": {"foo": {}}},
)
with ctx.manager(ctx.on.start(), state=state) as mgr:
out = mgr.run()
callback(mgr.charm)
with ctx(ctx.on.start(), state=state) as event:
out = event.run()
callback(event.charm)

if make_dirs:
# file = (out.get_container("foo").mounts["foo"].source + "bar/baz.txt").open("/foo/bar/baz.txt")
Expand Down Expand Up @@ -318,8 +318,8 @@ 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:
container = mgr.charm.unit.get_container("foo")
with ctx(ctx.on.start(), state) as event:
container = event.charm.unit.get_container("foo")
proc = container.exec(["foo"])
with pytest.raises(ExecError):
proc.wait()
Expand All @@ -340,8 +340,8 @@ 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:
container = mgr.charm.unit.get_container("foo")
with ctx(ctx.on.start(), state) as event:
container = event.charm.unit.get_container("foo")
proc = container.exec(["foo"])
out, err = proc.wait_output()
assert out == "hello pebble"
Expand All @@ -360,8 +360,8 @@ 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:
container = mgr.charm.unit.get_container("foo")
with ctx(ctx.on.start(), state) as event:
container = event.charm.unit.get_container("foo")
proc = container.exec(["foo"])
with pytest.raises(ExecError):
proc.wait_output()
Expand All @@ -381,10 +381,10 @@ 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")
) as event:
container = event.charm.unit.get_container("foo")
assert container.get_notices() == [n._to_ops() for n in notices]


Expand Down
4 changes: 2 additions & 2 deletions tests/test_e2e/test_relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,8 @@ 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:
charm = mgr.charm
with ctx(ctx.on.relation_broken(rel), state=State(relations={rel})) as event:
charm = event.charm

assert charm.model.get_relation("foo") is None
assert charm.model.relations["foo"] == []
Expand Down
10 changes: 5 additions & 5 deletions tests/test_e2e/test_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ 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
assert mgr.charm.model.resources.fetch("bar") == resource2.path
) as event:
assert event.charm.model.resources.fetch("foo") == resource1.path
assert event.charm.model.resources.fetch("bar") == resource2.path
with pytest.raises(NameError):
mgr.charm.model.resources.fetch("baz")
event.charm.model.resources.fetch("baz")
Loading

0 comments on commit c8a7c75

Please sign in to comment.