forked from canonical/operator
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor!: use ops._main._Manager in Scenario (canonical#1491)
The ops._main._Manager class started out as the Ops class in Scenario, but went through significant modifications before it was added in ops. This PR merges those changes back into Scenario, so that it's also using the `_Manager` class. Other than generally simplifying the code, this also means that Scenario is even more consistent with using ops in production. This includes some small refactoring in ops to make this cleaner: * `ops._main._emit_charm_event` is only used by `_Manager`, so logically belongs inside that class. This also makes it much simpler to have a subclass override the behaviour. * Allow passing a `JujuContext` to `_Manager`, so that it can be populated by something other than `os.environ`. * Allow `_Manager` subclasses to override how the charm metadata is loaded. * Allow `_Manager` subclasses to execute code after committing but before the framework is closed. * Support a dispatch path that uses `_` rather than `-` in event names (Scenario consistently uses `_`, even though this would always be `-` from Juju - feel free to push back on this, requiring Scenario to undo the `_` conversion when populating the context). The Scenario `runtime` and `ops_main_mock` modules are renamed to properly indicate that everything inside of them is an implementation detail and should be considered private by Scenario users. This is a breaking change but we're considering it a bug fix and not one that requires a major release, given that the documentation and top-level namespace were already clear on this. Note that the first commit does the rename and nothing else - but GitHub shows only one of the files as a rename (I think because too much content has then changed). It might be easier to read through the commits separately when reviewing. Significant Scenario refactoring: * UnitStateDB is moved from `runtime` to `ops_main_mock`, and it no longer (re) opens the SQLiteStorage - it instead takes a reference to the one that's in the framework, so that we're consistently using the same underlying storage, without relying on the filename being consistent (this eases moving to `:memory:` in the future). * As far as I can tell, `runtime._OpsMainContext` is completely unused and missed being removed in previous refactoring, so it's entirely deleted. * Loading and storing deferred events and stored state into the state database is moved from the runtime to the manager subclass. * The `Ops` class survives, but is now a subclass of the `ops._main._Manager` class. A reasonable amount of functionality is now removed (because the parent handles it), and the remaining code is specifically for handling the testing framework. A few type hints are added in some tests where I was working on fixing the test and it helped to do that. Fixes canonical#1425
- Loading branch information
1 parent
ad85c7f
commit 0ad731a
Showing
13 changed files
with
294 additions
and
483 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
#!/usr/bin/env python3 | ||
# Copyright 2023 Canonical Ltd. | ||
# See LICENSE file for licensing details. | ||
|
||
import dataclasses | ||
import marshal | ||
import re | ||
import sys | ||
from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Sequence, Set | ||
|
||
import ops | ||
import ops.jujucontext | ||
import ops.storage | ||
|
||
from ops.framework import _event_regex | ||
from ops._main import _Dispatcher, _Manager | ||
from ops._main import logger as ops_logger | ||
|
||
from .errors import BadOwnerPath, NoObserverError | ||
from .logger import logger as scenario_logger | ||
from .mocking import _MockModelBackend | ||
from .state import CharmType, StoredState, DeferredEvent | ||
|
||
if TYPE_CHECKING: # pragma: no cover | ||
from .context import Context | ||
from .state import State, _CharmSpec, _Event | ||
|
||
EVENT_REGEX = re.compile(_event_regex) | ||
STORED_STATE_REGEX = re.compile( | ||
r"((?P<owner_path>.*)\/)?(?P<_data_type_name>\D+)\[(?P<name>.*)\]", | ||
) | ||
|
||
logger = scenario_logger.getChild("ops_main_mock") | ||
|
||
# pyright: reportPrivateUsage=false | ||
|
||
|
||
class UnitStateDB: | ||
"""Wraps the unit-state database with convenience methods for adjusting the state.""" | ||
|
||
def __init__(self, underlying_store: ops.storage.SQLiteStorage): | ||
self._db = underlying_store | ||
|
||
def get_stored_states(self) -> FrozenSet["StoredState"]: | ||
"""Load any StoredState data structures from the db.""" | ||
db = self._db | ||
stored_states: Set[StoredState] = set() | ||
for handle_path in db.list_snapshots(): | ||
if not EVENT_REGEX.match(handle_path) and ( | ||
match := STORED_STATE_REGEX.match(handle_path) | ||
): | ||
stored_state_snapshot = db.load_snapshot(handle_path) | ||
kwargs = match.groupdict() | ||
sst = StoredState(content=stored_state_snapshot, **kwargs) | ||
stored_states.add(sst) | ||
|
||
return frozenset(stored_states) | ||
|
||
def get_deferred_events(self) -> List["DeferredEvent"]: | ||
"""Load any DeferredEvent data structures from the db.""" | ||
db = self._db | ||
deferred: List[DeferredEvent] = [] | ||
for handle_path in db.list_snapshots(): | ||
if EVENT_REGEX.match(handle_path): | ||
notices = db.notices(handle_path) | ||
for handle, owner, observer in notices: | ||
try: | ||
snapshot_data = db.load_snapshot(handle) | ||
except ops.storage.NoSnapshotError: | ||
snapshot_data: Dict[str, Any] = {} | ||
|
||
event = DeferredEvent( | ||
handle_path=handle, | ||
owner=owner, | ||
observer=observer, | ||
snapshot_data=snapshot_data, | ||
) | ||
deferred.append(event) | ||
|
||
return deferred | ||
|
||
def apply_state(self, state: "State"): | ||
"""Add DeferredEvent and StoredState from this State instance to the storage.""" | ||
db = self._db | ||
for event in state.deferred: | ||
db.save_notice(event.handle_path, event.owner, event.observer) | ||
try: | ||
marshal.dumps(event.snapshot_data) | ||
except ValueError as e: | ||
raise ValueError( | ||
f"unable to save the data for {event}, it must contain only simple types.", | ||
) from e | ||
db.save_snapshot(event.handle_path, event.snapshot_data) | ||
|
||
for stored_state in state.stored_states: | ||
db.save_snapshot(stored_state._handle_path, stored_state.content) | ||
|
||
|
||
class Ops(_Manager): | ||
"""Class to manage stepping through ops setup, event emission and framework commit.""" | ||
|
||
def __init__( | ||
self, | ||
state: "State", | ||
event: "_Event", | ||
context: "Context[CharmType]", | ||
charm_spec: "_CharmSpec[CharmType]", | ||
juju_context: ops.jujucontext._JujuContext, | ||
): | ||
self.state = state | ||
self.event = event | ||
self.context = context | ||
self.charm_spec = charm_spec | ||
self.store = None | ||
|
||
model_backend = _MockModelBackend( | ||
state=state, | ||
event=event, | ||
context=context, | ||
charm_spec=charm_spec, | ||
juju_context=juju_context, | ||
) | ||
|
||
super().__init__( | ||
self.charm_spec.charm_type, model_backend, juju_context=juju_context | ||
) | ||
|
||
def _load_charm_meta(self): | ||
metadata = (self._charm_root / "metadata.yaml").read_text() | ||
actions_meta = self._charm_root / "actions.yaml" | ||
if actions_meta.exists(): | ||
actions_metadata = actions_meta.read_text() | ||
else: | ||
actions_metadata = None | ||
|
||
return ops.CharmMeta.from_yaml(metadata, actions_metadata) | ||
|
||
def _setup_root_logging(self): | ||
# Ops sets sys.excepthook to go to Juju's debug-log, but that's not | ||
# useful in a testing context, so we reset it here. | ||
super()._setup_root_logging() | ||
sys.excepthook = sys.__excepthook__ | ||
|
||
def _make_storage(self, _: _Dispatcher): | ||
# TODO: add use_juju_for_storage support | ||
# TODO: Pass a charm_state_path that is ':memory:' when appropriate. | ||
charm_state_path = self._charm_root / self._charm_state_path | ||
storage = ops.storage.SQLiteStorage(charm_state_path) | ||
logger.info("Copying input state to storage.") | ||
self.store = UnitStateDB(storage) | ||
self.store.apply_state(self.state) | ||
return storage | ||
|
||
def _get_event_to_emit(self, event_name: str): | ||
owner = ( | ||
self._get_owner(self.charm, self.event.owner_path) | ||
if self.event | ||
else self.charm.on | ||
) | ||
|
||
try: | ||
event_to_emit = getattr(owner, event_name) | ||
except AttributeError: | ||
ops_logger.debug("Event %s not defined for %s.", event_name, self.charm) | ||
raise NoObserverError( | ||
f"Cannot fire {event_name!r} on {owner}: " | ||
f"invalid event (not on charm.on).", | ||
) | ||
return event_to_emit | ||
|
||
@staticmethod | ||
def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: | ||
"""Walk path on root to an ObjectEvents instance.""" | ||
obj = root | ||
for step in path: | ||
try: | ||
obj = getattr(obj, step) | ||
except AttributeError: | ||
raise BadOwnerPath( | ||
f"event_owner_path {path!r} invalid: {step!r} leads to nowhere.", | ||
) | ||
if not isinstance(obj, ops.ObjectEvents): | ||
raise BadOwnerPath( | ||
f"event_owner_path {path!r} invalid: does not lead to " | ||
f"an ObjectEvents instance.", | ||
) | ||
return obj | ||
|
||
def _close(self): | ||
"""Now that we're done processing this event, read the charm state and expose it.""" | ||
logger.info("Copying storage to output state.") | ||
assert self.store is not None | ||
deferred = self.store.get_deferred_events() | ||
stored_state = self.store.get_stored_states() | ||
self.state = dataclasses.replace( | ||
self.state, deferred=deferred, stored_states=stored_state | ||
) |
Oops, something went wrong.