Skip to content

Commit

Permalink
Merge branch '7.0' into add-pebble-check-events
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyandrewmeyer authored Jul 24, 2024
2 parents e5e454c + 54c9b7a commit f718af0
Show file tree
Hide file tree
Showing 12 changed files with 90 additions and 104 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
venv/
build/
docs/build/
docs/_build/
*.charm
.tox/
.coverage
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,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']
Expand All @@ -991,17 +991,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()
)

# ...
```
Expand Down
2 changes: 2 additions & 0 deletions docs/custom_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ 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'),
('py:class', 'scenario.state._max_posargs.<locals>._MaxPositionalArgs'),
]
2 changes: 0 additions & 2 deletions scenario/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# See LICENSE file for licensing details.
from scenario.context import ActionOutput, Context
from scenario.state import (
Action,
ActiveStatus,
Address,
BindAddress,
Expand Down Expand Up @@ -38,7 +37,6 @@
)

__all__ = [
"Action",
"ActionOutput",
"CheckInfo",
"CloudCredential",
Expand Down
4 changes: 2 additions & 2 deletions scenario/consistency_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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],
):
Expand Down
38 changes: 22 additions & 16 deletions scenario/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
from scenario.logger import logger as scenario_logger
from scenario.runtime import Runtime
from scenario.state import (
Action,
CheckInfo,
Container,
MetadataNotFoundError,
Notice,
Secret,
Storage,
_Action,
_CharmSpec,
_Event,
_max_posargs,
Expand All @@ -28,7 +28,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]

Expand Down Expand Up @@ -83,7 +83,7 @@ class _Manager:
def __init__(
self,
ctx: "Context",
arg: Union[str, Action, _Event],
arg: Union[str, _Action, _Event],
state_in: "State",
):
self._ctx = ctx
Expand Down Expand Up @@ -162,7 +162,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
Expand Down Expand Up @@ -338,6 +338,17 @@ def pebble_check_recovered(container: Container, info: CheckInfo):
check_info=info,
)

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.
Expand Down Expand Up @@ -604,7 +615,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:
Expand Down Expand Up @@ -634,23 +645,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)

Expand All @@ -669,11 +680,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(
Expand Down
37 changes: 17 additions & 20 deletions scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Final,
FrozenSet,
Generic,
Iterable,
List,
Literal,
Optional,
Expand Down Expand Up @@ -1216,34 +1217,34 @@ 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.
[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.
"""
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.
Expand All @@ -1255,7 +1256,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."""
Expand Down Expand Up @@ -1312,9 +1313,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))

Expand Down Expand Up @@ -1671,7 +1671,7 @@ class _Event:
check_info: Optional[CheckInfo] = None
"""If this is a Pebble check event, the check info it provides."""

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)
Expand Down Expand Up @@ -1830,15 +1830,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
Expand All @@ -1853,11 +1855,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],
Expand Down
Loading

0 comments on commit f718af0

Please sign in to comment.