Skip to content

Commit

Permalink
Unify run() and run_action().
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyandrewmeyer committed Aug 2, 2024
1 parent ad6e105 commit 56545a4
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 161 deletions.
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,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:

Expand All @@ -975,18 +974,18 @@ 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())
out: scenario.ActionOutput = 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'}
# If the action did not fail, we can read the results:
assert out.results == {'foo': 'bar'}

else:
# If the action fails, we can read a failure message:
assert out.failure == 'boo-hoo'
# If the action fails, we can read a failure message:
assert out.failure == 'boo-hoo'
```

## Parametrized Actions
Expand All @@ -999,7 +998,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(
out: scenario.ActionOutput = ctx.run(
ctx.on.action("do_backup", params={'a': 'b'}),
scenario.State()
)
Expand Down Expand Up @@ -1137,7 +1136,6 @@ 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:
# 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:
Expand Down
6 changes: 4 additions & 2 deletions scenario/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/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, Task
from scenario.mocking import ActionFailed
from scenario.state import (
ActiveStatus,
Address,
Expand Down Expand Up @@ -37,7 +38,8 @@
)

__all__ = [
"ActionOutput",
"Task",
"ActionFailed",
"CheckInfo",
"CloudCredential",
"CloudSpec",
Expand Down
153 changes: 51 additions & 102 deletions scenario/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,35 +38,48 @@


@dataclasses.dataclass(frozen=True)
class ActionOutput(_max_posargs(0)):
"""Wraps the results of running an action event with ``run_action``."""
class Task(_max_posargs(0)):
"""Wraps the results of running an action event on a unit with ``run``."""

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]
logs: List[str] = dataclasses.field(default_factory=list)
"""Any logs associated with the action output, set by the charm with
:meth:`ops.ActionEvent.log`."""

results: Optional[Dict[str, Any]] = None
"""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
status: str = "pending"

# Note that in the Juju struct, this is called "fail".
failure_message: str = ""

def set_status(self, status):
"""Set the status of the task."""
# bypass frozen dataclass
object.__setattr__(self, "status", status)

def set_failure_message(self, message):
"""Record an explanation of why this task failed."""
# bypass frozen dataclass
object.__setattr__(self, "failure_message", message)

def set_results(self, results: Dict[str, Any]):
"""Set the results of the action."""
if self.results is None:
# bypass frozen dataclass
object.__setattr__(self, "results", results)
else:
self.results.clear()
self.results.update(results)


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):
Expand All @@ -77,7 +90,7 @@ class AlreadyEmittedError(RuntimeError):
"""Raised when ``run()`` is called more than once."""


class _Manager:
class Manager:
"""Context manager to offer test code some runtime charm object introspection."""

def __init__(
Expand All @@ -93,30 +106,27 @@ def __init__(
self._emitted: bool = False

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):
return self._ctx._run # noqa

def _get_output(self):
raise NotImplementedError("override in subclass")

def __enter__(self):
self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in)
ops = wrapped_ctx.__enter__()
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.
Expand All @@ -128,33 +138,15 @@ def _run(self) -> Union[ActionOutput, "State"]:
# wrap up Runtime.exec() so that we can gather the output state
self._wrapped_ctx.__exit__(None, None, None)

return self._get_output()
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("user didn't emit the event within the context manager scope. Doing so implicitly upon exit...")
# The output is discarded so we can use the private method.
self._run()


class ManagedEvent(_Manager):
charm: CharmBase # type: ignore

def run(self) -> "State":
return cast("State", super()._run())

def _get_output(self):
return self._ctx._output_state # noqa


class ManagedAction(_Manager):
charm: CharmBase # type: ignore

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

def _get_output(self):
return self._ctx._finalize_action(self._ctx.output_state) # noqa
logger.debug(
"user didn't emit the event within the context manager scope. Doing so implicitly upon exit...",
)
self.run()


class _CharmEvents:
Expand Down Expand Up @@ -349,14 +341,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``:
Expand Down Expand Up @@ -435,13 +425,11 @@ def __init__(
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:
Expand Down Expand Up @@ -546,11 +534,8 @@ 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_history: List[Task] = []

self.on = _CharmEvents()

Expand Down Expand Up @@ -600,18 +585,11 @@ def __call__(self, event: "_Event", state: "State"):
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` 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 ManagedAction(self, event, state)
return ManagedEvent(self, event, state)
return Manager(self, event, state)

def run(self, event: "_Event", state: "State") -> "State":
"""Trigger a charm execution with an Event and a State.
Expand All @@ -624,42 +602,13 @@ def run(self, event: "_Event", state: "State") -> "State":
charm will invoke when handling the Event.
"""
if event.action:
raise InvalidEventError("Use run_action() to run an action event.")
self.action_history.append(Task())
with self._run(event=event, state=state) as ops:
ops.emit()
if event.action:
self.action_history[-1].set_status("completed")
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 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)

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

@contextmanager
def _run(self, event: "_Event", state: "State"):
runtime = Runtime(
Expand Down
13 changes: 10 additions & 3 deletions scenario/mocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ class ActionMissingFromContextError(Exception):
# this flow.


class ActionFailed(Exception):
def __init__(self, message: str):
self.message = message


class _MockExecProcess:
def __init__(self, command: Tuple[str, ...], change_id: int, out: "ExecOutput"):
self._command = command
Expand Down Expand Up @@ -528,21 +533,23 @@ 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
self._context.action_history[-1].set_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_history[-1].set_status("failed")
self._context.action_history[-1].set_failure_message(message)
raise ActionFailed(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_history[-1].logs.append(message)

def action_get(self):
action = self._event.action
Expand Down
3 changes: 2 additions & 1 deletion scenario/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from scenario.capture_events import capture_events
from scenario.logger import logger as scenario_logger
from scenario.mocking import ActionFailed
from scenario.ops_main_mock import NoObserverError
from scenario.state import DeferredEvent, PeerRelation, StoredState

Expand Down Expand Up @@ -466,7 +467,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(
Expand Down
2 changes: 1 addition & 1 deletion scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,7 @@ class _Action(_max_posargs(1)):
def test_backup_action():
ctx = scenario.Context(MyCharm)
out: scenario.ActionOutput = ctx.run_action(
out: scenario.ActionOutput = ctx.run(
ctx.on.action('do_backup', params={'filename': 'foo'}),
scenario.State()
)
Expand Down
Loading

0 comments on commit 56545a4

Please sign in to comment.